From afb4e82943d141d8dd19ef3bb6f775999eecd771 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 14:53:08 +0200 Subject: [PATCH 001/138] =?UTF-8?q?docs:=20Campaign=20LA=20design=20spec?= =?UTF-8?q?=20=E2=80=94=20launcher/installer/updater=20+=20char=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved brainstorm outcome for the alpha launcher campaign: Approach A file-contract orchestrator (session config in, stdin credential, JSONL status events out), full in-UI CRUD for servers/accounts/credentials, headless character-list probe, retail character-select screen (no Create), plugins + login commands on both hosts, first-run DAT locate/bake install, GitHub Releases update feed. Co-Authored-By: Claude Fable 5 --- .../2026-08-14-launcher-campaign-design.md | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-launcher-campaign-design.md diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md new file mode 100644 index 00000000..1003a54a --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -0,0 +1,326 @@ +# Campaign LA — Launcher / Installer / Updater + character-select screen (design spec) + +**Date:** 2026-08-14 +**Status:** APPROVED design, pre-plan +**Plan doc (next step):** `docs/plans/2026-08-14-launcher-campaign.md` (to be written) +**Prior decisions consumed:** `claude-memory/project_launcher_direction.md` (2026-08-09) + +Campaign LA is distinct from Modern Runtime **Slice L** (Linux graphical, +parked at L1) — "LA" is a campaign identifier in the N/V/P/A/CH/OP/FA +series, not a slice. + +## 1. Goal + +One external product — the **acdream launcher** — that is simultaneously +the installer, the updater, and the multi-server / multi-account / +multi-character session launcher (ThwargLauncher UX model), on Windows and +Linux. Plus the one client-side feature the launcher flow exposes as +missing: the **retail character-selection screen**. + +Distribution model: alpha users receive ONLY the launcher (per-RID +self-contained single-file publish). The launcher fetches the client from +the release feed, locates retail DATs, runs the pak bake, and launches +sessions. + +## 2. Decisions log + +Pinned 2026-08-09 (NOT re-litigated here): + +| Decision | Value | +|---|---| +| UI stack | Avalonia (Windows + Linux day one) | +| Project split | `AcDream.Launcher` (thin Avalonia) + `AcDream.Launcher.Core` (BCL-only) | +| Credentials | **Plaintext file, user-decided.** 0600 on Linux; never in logs/crash bundles | +| UX reference | ThwargLauncher (servers × accounts × character pre-select) | +| Launch contract | Reuse Slice K1's strict portable config shape | +| Paths | Consume Slice L0's `ApplicationPathSet` XDG/Windows contract | + +Decided this session (2026-08-14): + +| Question | Decision | +|---|---| +| Headless launch purpose | **Run plugins** (VirindiTank-style) + login commands; same on GUI. Launcher selects which plugins per character | +| Character-select screen | **Retail screen, no Create.** 3D rotating preview + Enter World + Delete; Create Character deferred to its own campaign | +| Campaign scope | **Everything now** — launch flows + first-run install + update pipeline in one campaign | +| Launcher ↔ client coupling | **Approach A: file-contract orchestrator** (config in, status events out; no game-protocol code in the launcher) | +| Update feed | **GitHub Releases** (manifest.json + per-RID zips as release assets) | +| Profile editing | **Full CRUD in the launcher UI** — add/edit/remove servers, accounts, passwords, per-character settings. The JSON file is storage (hand-editable as a bonus), never the required interface | +| Character enumeration | **On-demand probe**: launcher spawns the headless host in a probe mode (connect → `CharacterList` → status event → graceful disconnect BEFORE entering world → exit) and folds the roster into the profile store | + +Rejected: the launcher itself embedding Runtime/Core.Net to speak the +game protocol — the character probe runs in the headless host via the +normal launch contract, so the launcher stays protocol-free. The probe +never enters the world; a graceful account-level disconnect at the +character-list stage is the same dance every normal login performs, so +the ACE stale-session landmine (hard-killed in-world sessions poisoning +the account ~3 min) does not apply on the happy path. Deferred: live IPC +fleet dashboard (the status-file format is its forward seam), Create +Character, community server-list import. + +## 3. Architecture — file-contract orchestrator + +The launcher never speaks the game protocol and references nothing from +the game solution except a new tiny platform assembly. Its contracts with +the client are exactly three: + +1. **Config in** — a per-launch session config file (K1 shape, extended). +2. **Credential in** — password piped to child stdin (K1 `StandardInput` + provider). +3. **Status out** — a per-session JSON-lines event file the launcher tails. + +Character enumeration has two feeds, both flowing through the same +status-stream vocabulary: + +1. **Cache-from-observation** — hosts report the account's + `CharacterList` in the status stream on every login; the launcher + folds it into its profile store. +2. **On-demand probe** — a "refresh characters" action per account spawns + the headless host with a probe-mode session config: connect, receive + `CharacterList`, emit the status event, gracefully disconnect + **without entering the world**, exit. The launcher folds the roster in + exactly as in (1). The launcher refuses to probe an account it is + itself currently running a session for; an externally-active session + makes the probe fail gracefully (reported on the status stream, never + an exception in the launcher). + +A never-seen account can therefore be enumerated before its first real +launch, or simply launched in `guiSelect` mode and picked in-client. + +## 4. Components + +- **`AcDream.Launcher.Core`** (new, BCL-only): profile store + (load/save/validate/merge-charlist), session-config composition, process + spawn + supervision + stdin credential feed, status-event reader, + install engine (DAT locate/validate, bake-tool invocation, SHA verify), + update engine (manifest client, download, SHA verify, versioned install, + pointer swap), self-update stager. Fully unit-testable. +- **`AcDream.Launcher`** (new, Avalonia): MVVM shell over Launcher.Core. + Server list → accounts → characters tree with **full CRUD in the UI**: + add/edit/remove servers (name/host/port), add/edit/remove accounts + (account name + password entry), per-character settings editor (launch + mode / plugins / login commands), a per-account "refresh characters" + probe action, session status column, first-run install wizard, update + prompts. Hand-editing the JSON is never required for any flow. +- **`AcDream.Platform`** (new, tiny, BCL-only): `ApplicationPathSet` + + `IApplicationPathEnvironment` move here from + `src/AcDream.Runtime/Platform/ApplicationPathSet.cs`. Runtime, App, + Headless, Launcher.Core reference it. Dependency guards (K0 family) + amended deliberately in the same commit. +- **`AcDream.App`**: gains `--session-config ` CLI ingestion into + `RuntimeOptions` (env-var dev workflow untouched), the `StandardInput` + credential resolver, the status-event writer, the launcher-selected + plugin set, login-command execution, and the character-select screen. +- **`AcDream.Headless`**: gains the two config fields (`Plugins`, + `LoginCommands`), an `idle` consumer policy (enter world, run + plugins/commands, stay until stopped), the probe mode (§3/§6), plugin + hosting, and the same status-event writer. + +## 5. Profile & credential store + +One JSON file, created and maintained entirely by the launcher UI (the +CRUD flows in §4): `ConfigDirectory/launcher-profiles.json` +(`%APPDATA%\acdream\` / `~/.config/acdream/`), permissions 0600 on Linux. +Hand-editability is a property of the format, not a required workflow. + +```json +{ + "version": 1, + "servers": [ + { + "name": "Local ACE", + "host": "127.0.0.1", + "port": 9000, + "accounts": [ + { + "account": "testaccount", + "password": "testpassword", + "characters": [ + { + "name": "+Acdream", + "id": "0x5000000A", + "launchMode": "gui", + "plugins": ["ExamplePlugin"], + "loginCommands": ["/tell someone, hi", "/vt start"] + } + ] + } + ] + } + ] +} +``` + +- `characters[]` = launcher-maintained cache (name/id, fed by status + events) + user settings (`launchMode`, `plugins`, `loginCommands`). +- `launchMode`: `gui` (straight to world), `guiSelect` (GUI, stop at + character-select screen; default when no character chosen), `headless`. +- Passwords live in this file and NOWHERE else: never in process + arguments, never in session configs, never in logs (K1 redaction + discipline extends to the launcher). +- Server entries are manual-add (name/host/port). No published-list + import this campaign. + +## 6. Launch contract + +**Session config** (written to +`CacheDirectory/launcher/sessions//session.json`): the K1 +`HeadlessConfiguration` shape extended with: + +- `Plugins: string[]` — plugin names to load from the standard + `PluginsDirectory`; hosts load exactly this set. +- `LoginCommands: string[]` — ordered chat-typed strings. +- Graphical host: `Character` selector may be ABSENT → character-select + screen instead of auto-enter. +- `Content` descriptor (existing K1 field): `DatDirectory` + + `PreparedAssetPath`, filled from the launcher's install records. + +**Spawn:** + +- Headless: `AcDream.Headless --config ` (existing CLI). +- GUI: `AcDream.App --session-config ` (new; parsed once in + `Program.cs` into `RuntimeOptions` per code-structure rule 4). +- Probe: `AcDream.Headless --config ` with a probe-mode session + (connect → `characterList` status event → graceful disconnect before + `EnterWorld` → exit). Today's config loader requires a character + selector and a policy per session (`JsonRequired`); probe mode relaxes + that for the probe shape only. +- Credential: K1 `StandardInput` provider; launcher writes the password + to child stdin then closes it. Headless supports this today; App gains + the resolver. + +**Status stream** +(`CacheDirectory/launcher/sessions//status.jsonl`), appended by both +hosts, one JSON object per line: + +`started`, `connected`, `characterList` (names + ids + slots), +`enteredWorld` (id + name), `pluginLoaded` / `pluginFailed` (name + +error), `disconnected`, `exited` (code + reason). + +The launcher tails this for live per-session UI state and folds +`characterList` into the profile store. This exact event vocabulary is +the seam a future IPC channel (fleet dashboard) replaces — same events, +different transport — so event names/payloads are versioned from day one +(`"v": 1` per line). + +## 7. Character-select screen (client-side, retail) + +A new pre-world session state between `CharacterList` receipt and +`EnterWorld`. Today `LiveSessionController` (`TrySelectCharacter`, +`src/AcDream.Runtime/Session/LiveSessionController.cs`) auto-selects and +enters immediately. New behavior: **no character selector in options → +stop at the retail character-select screen.** Selection there feeds the +same `EnterWorld` path. + +- **Ownership:** J-owner pattern. A Runtime-owned selection state (roster, + highlighted entry, pending-delete confirmation) with typed commands + (highlight / enter / delete-request / delete-confirm); App projects the + authored screen. Headless never uses it (config always carries a + selector; the loader already requires one). +- **UI:** imported retail screen via `LayoutImporter` (OP3/FA recipe). + The concrete LayoutDesc id and widget tree come from the + grep-named-first workflow + `docs/research/retail-ui/` during the plan; + retail decomp is the behavior oracle for list interaction, Enter World, + and Delete (including retail's delete confirmation flow — ACE serves + the character-delete message). +- **3D preview:** a void-scene render path — the selected character's + Setup + ObjDesc appearance rendered with a dedicated camera/lighting, + reusing the existing creature-appearance pipeline outside a landblock. + Own slice; the retail screen's rotation behavior is the oracle. +- **Non-goals:** Create Character (own future campaign); a login screen + ("back" exits the client — credentials always arrive via config/env). +- Retail-workflow rules apply: any behavioral deviation ships with its + divergence-register row in the same commit. + +## 8. Plugins & login commands on both hosts + +- **Plugin loading** mechanics already live in Core + (`src/AcDream.Core/Plugins/PluginLoader.cs`, collectible ALC, + `IPluginHost` from `AcDream.Plugin.Abstractions`). This campaign makes + the loaded SET session-config-driven on both hosts. +- **Headless plugin host:** an `IPluginHost` implementation over Runtime + state. UI-only surfaces (`IUiRegistry.AddMarkupPanel`, etc.) become + explicit no-ops behind a capability flag so plugins can detect headless. + Contract documented in `Plugin.Abstractions`. +- **Login commands** run *as if typed into chat*: sequentially, with a + default 500 ms inter-command delay (config-overridable per session), + starting at entered-world. Failures are logged to the status stream and + do not abort the session. +- **The parser seam:** chat-string parsing/dispatch (`ChatInputParser`, + `ChatCommandRouter`, 152-verb `RetailClientCommandCatalog`) lives in + `AcDream.UI.Abstractions`, unreachable from Headless under the K0 + dependency guard. The campaign extracts the parsing/dispatch CORE to a + location both hosts reach — Runtime vs a small shared assembly is + decided in the plan after reading the CH seams. The K0 guard amendment + is a deliberate, documented change in the same slice. GUI chat behavior + must be bit-identical before/after the extraction (CH campaign is + closed and user-accepted; this must not reopen it). + +## 9. Installer / updater + +- **First-run wizard:** auto-detect DAT directories + (`%USERPROFILE%\Documents\Asheron's Call`, `C:\Turbine\Asheron's Call`) + + manual picker; validate the four DAT files; run bake tool 4 with a + real progress UI (~30 GB read); SHA-verify the pak; record + `DatDirectory` + `PreparedAssetPath` for session configs. +- **Client install/update:** poll the GitHub Releases feed's + `manifest.json` (version, per-RID zip URL, SHA-256); download; verify; + install to `DataDirectory/app//`; atomic pointer swap + (`current.json`); never while any session is running; keep the previous + version for one-step rollback. +- **Launcher self-update:** same feed; staged download; rename-dance swap + on next start (a running exe can't replace itself on Windows). +- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips + are release assets; the launcher pins the repo/owner in its config. + +## 10. Testing + +- **Launcher.Core unit tests** (new test project, registered in + `AcDream.slnx`): profile round-trip + merge, full CRUD operations + (add/edit/remove servers/accounts/characters surviving save/load), + session-config composition (including the probe shape), manifest/SHA + against a local HTTP fixture, process supervision + stdin feed against + a fake child, self-update staging. +- **Launch contract:** App/Headless suite round-trips — `--session-config` + → `RuntimeOptions`, stdin credential resolution, status-event writer + output shape, plugin-set narrowing, login-command execution order. +- **Character-select:** Runtime selection-state tests; authored screen + via UI Studio dumps/screenshots; behavior against retail oracle. +- **Headless plugin host:** fixture plugin in the Headless suite + (load, capability flag, teardown). +- **Connected gates (user-driven):** every launch mode against local ACE + (gui / guiSelect / headless), the character probe (fresh account → + refresh → roster appears, and repeated probes leaving no stale ACE + session), clean-profile first-run wizard end-to-end, staged-manifest + update swap, character-select visual matrix, delete flow, + login-commands + plugin observable behavior on both hosts, and + add-server/add-account flows done purely through the UI. + +## 11. Risks / open items for the plan phase + +1. Parser-extraction landing spot (Runtime vs shared assembly) — decide + after reading CH seams; do not regress CH-accepted chat behavior. +2. Current App plugin-loading behavior (what loads today, when) — read + before wiring the session-driven set. +3. Retail character-select LayoutDesc id + widget tree — research task + (grep-named + `docs/research/retail-ui/`). +4. Character-delete wire message + ACE handling — verify against + holtburger/ACE before implementing the Delete flow. +5. Void-scene preview: lighting/camera parameters need the retail oracle + (what does retail actually render behind the character?). +6. Bake tool 4 invocation surface from Launcher.Core (in-process reference + vs child process) — child process preferred to keep Launcher.Core free + of game-solution references; confirm the tool's CLI is sufficient. +7. `AcDream.Platform` extraction touches K0-family dependency guards — + amend the guard assertions in the same commit, never loosen silently. +8. Windows profile-file permissions: 0600 is Linux hygiene; Windows keeps + default user-profile ACLs (no extra hardening — accepted plaintext + posture). +9. Probe semantics: verify against ACE source (and one live check) that a + graceful disconnect at the character-list stage leaves no lingering + account session — the design assumes the landmine is exclusive to + hard-killed in-world sessions. Also verify ACE's behavior when a probe + hits an account with an externally-active session (reject vs boot), + and make the probe's failure path graceful either way. +10. Probe config shape: the headless loader's `JsonRequired` character + selector + policy need a deliberate relaxation for probe sessions + only — normal sessions keep strict validation. From 9b0bb2558100e906a89e31bc91bdb46651f0075f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:10:39 +0200 Subject: [PATCH 002/138] =?UTF-8?q?docs:=20Campaign=20LA=20plan=20?= =?UTF-8?q?=E2=80=94=20slices=20LA0-LA11,=20recon-grounded;=20spec=20corre?= =?UTF-8?q?ctions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan doc with twelve slices, dependencies, review protocol (Opus dual-lens: architectural + retail-faithful), and ledger. Three parallel recon reports grounded the slice bodies: - Retail select screen is gmCharacterManagementUI: flat listbox + Enter/Delete/Restore + dialogs. NO 3D preview (that machinery is chargen-only gmCG3DView) — the spec 3D-preview slice is deleted, the old retail-ui/05-panels.md pedestal claim is uncited and wrong. Restore + CharacterError join scope; delete sends account+slot. - Chat-command core (parser/router/catalog/ChatVM) is dependency-clean BCL+Core; extraction to Runtime is a move, not a rewrite. - Probe reuses the NoCharacters early-exit shape (graceful teardown at the CharacterList stage exists today); roster plumbing is new. - Bake tool needs --progress-json + explicit --out; no whole-file SHA exists — launcher records/verifies its own. - UI Studio is deleted (Campaign V) — stale references corrected. Roadmap + CLAUDE.md Current state carry the campaign pointer. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 14 + docs/plans/2026-04-11-roadmap.md | 20 +- docs/plans/2026-08-14-launcher-campaign.md | 388 ++++++++++++++++++ .../2026-08-14-launcher-campaign-design.md | 88 ++-- 4 files changed, 484 insertions(+), 26 deletions(-) create mode 100644 docs/plans/2026-08-14-launcher-campaign.md diff --git a/CLAUDE.md b/CLAUDE.md index ad590b94..cbd115a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,6 +232,20 @@ J-owner, both retail open paths, staged-item trading marker AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired. Filed: #393 (texture-detail options, post-M4). +**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia +launcher/installer/updater (Windows+Linux) + the retail character- +management screen, driven autonomously under a user-set goal: Fable +plans, Sonnet implements, Opus dual-lens reviews (architectural + +retail-faithful). Spec: +`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan + +ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at +`claude-memory/project_launcher_direction.md`. Key recon corrections +already binding: retail's select screen (`gmCharacterManagementUI`) has +NO 3D preview (chargen-only machinery); UI Studio no longer exists +(deleted at Campaign V — ignore stale memory/docs claims otherwise); +App `Program.cs` has no subcommand dispatch (the `--session-config` flag +is additive). + **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- executor owner. Routes landed this session: 4b-3 remote teleport/cell-less diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c4fd0cf9..84534cdb 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-08-03. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-08-14. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. **Slice L checkpoint:** L0 closed at `66f114b2` with one typed graphical @@ -86,6 +86,24 @@ full Release suite 12,221 passed / 4 skipped / 0 failed. Plan and ledger: in-client acceptance script: [`2026-08-09-campaign-ch-test-script.md`](../research/2026-08-09-campaign-ch-test-script.md). +**Campaign LA — launcher/installer/updater + retail character-select +(ACTIVE 2026-08-14):** the alpha-program launcher: an Avalonia app +(Windows + Linux) doing triple duty — install (DAT locate → `acdream-bake` +with progress → SHA record), update (GitHub Releases manifest, verified +download, atomic version swap, launcher self-update), and launch +(ThwargLauncher-model server × account × character profiles with full +in-UI CRUD; plaintext credential file by explicit user decision). +File-contract orchestration of both hosts: session config in (K1 shape + +plugins + login commands), password via child stdin, versioned JSONL +status events out. Adds the headless character-list probe, plugin hosting ++ login commands on both hosts, and the retail character-management +screen (recon-corrected: `gmCharacterManagementUI` is a flat listbox with +Enter/Delete/Restore — NO 3D preview on retail's select screen; Create is +a future campaign). Spec: +[`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); +plan + ledger: +[`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). + **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md new file mode 100644 index 00000000..1a104e65 --- /dev/null +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -0,0 +1,388 @@ +# Campaign LA — launcher / installer / updater + retail character-select + +**Status:** ACTIVE (started 2026-08-14) +**Spec (approved):** `docs/superpowers/specs/2026-08-14-launcher-campaign-design.md` +**Memory crib:** `claude-memory/project_launcher_direction.md` +**Branch:** `claude/acdream-launcher-credentials-4d2f7c` (merge to main at coherent checkpoints) + +Campaign LA ships the alpha launcher (Avalonia, Windows + Linux): triple-duty +launcher + installer + updater, ThwargLauncher-model profiles with full in-UI +CRUD, plaintext credential file (user-decided), file-contract orchestration of +`AcDream.App` and `AcDream.Headless`, plugins + login commands on both hosts, +the headless character probe, and the retail character-select screen (no +Create). All architectural decisions live in the spec — this plan sequences +the work. + +## Process (binding) + +- **Fable plans/sequences/integrates. Sonnet implements bounded slices. Opus + reviews at every slice boundary, dual-lens:** (a) architectural — ownership, + layering, dependency-guard integrity, seams; (b) retail fidelity vs + `docs/research/named-retail/` wherever the slice touches retail behavior. + Findings → fixes → narrow re-review. +- Max 3–4 agents in parallel including children; subagents never spawn + subagents; implementer prompts carry spec+plan paths, files-to-read, + acceptance criteria, commit style. +- `dotnet build` + `dotnet test` green before a slice is DONE; ≥1 commit per + slice tagged `Campaign LA`; retail deviations add their + `docs/architecture/retail-divergence-register.md` row in the same commit; + no workarounds without explicit user approval. +- Connected/visual gates are the ONLY stop-and-wait points; each gets an + exact script under `docs/research/` and non-blocked slices keep moving. + +## Slice map + +| Slice | Deliverable | Depends on | +|---|---|---| +| LA0 | `AcDream.Platform` extraction (`ApplicationPathSet`) + guard amendments | — | +| LA1 | Launch contract: App `--session-config` + stdin credential; status.jsonl writer both hosts; roster plumbing | LA0 | +| LA2 | Headless probe mode + `idle` policy | LA1 | +| LA3 | `AcDream.Launcher.Core`: profile store CRUD, config composition, spawn/supervise, status reader | LA0 (LA1 contract shapes) | +| LA4 | `AcDream.Launcher` Avalonia UI: CRUD views, per-char settings, sessions, probe action | LA3 | +| LA5 | Plugin hosting: headless `IPluginHost` + capability flag; session-driven plugin set both hosts | LA1 | +| LA6 | Login commands: parser-core extraction + execution on both hosts | LA1, LA5 | +| LA7 | Character-select: Runtime selection state + wire (delete/restore/error) + no-selector flow | LA1 | +| LA8 | Character-select authored retail screen (flat listbox — NO 3D preview, recon-corrected) | LA7 | +| LA9 | Installer: first-run wizard (DAT locate/validate, bake w/ progress, SHA record) | LA3, LA4 | +| LA10 | Updater: GitHub Releases manifest, download/verify/install/swap, self-update | LA3, LA4 | +| LA11 | Closeout: connected-gate script, roadmap/CLAUDE.md/memory, program ledger | all | + +Parallelism guide: LA3/LA4 (launcher side) proceed alongside LA5–LA8 (client +side) — different assemblies, no shared files. LA9/LA10 close the launcher +side; LA11 closes the campaign. + +## LA0 — `AcDream.Platform` extraction + +New BCL-only project `src/AcDream.Platform/` holding `ApplicationPathSet` + +`IApplicationPathEnvironment` (today +`src/AcDream.Runtime/Platform/ApplicationPathSet.cs` — self-contained, no +intra-Runtime dependencies; clean cut). Runtime/App/Headless reference it. + +Recon facts (2026-08-14): blast radius is the definition, six source files +(`GraphicalHostPlatformServices.cs`, `GraphicalLegacyConfigurationMigrator.cs`, +`App/Program.cs`, `GameWindow.cs:533`, `HeadlessPathSet.cs`, +`HeadlessPlatformEnvironment.cs`; two more files are doc-comment-only), two +test files (`ApplicationPathSetTests.cs` moves to a new +`tests/AcDream.Platform.Tests/` or stays keyed to the new assembly; +`GraphicalLegacyConfigurationMigratorTests.cs` fixtures), and ONE dependency +guard: `tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs` +`HeadlessAssemblyReferencesOnlyTheRuntimeProject` asserts Headless references +exactly `[AcDream.Runtime.csproj]` — amend to the exact new set in the same +commit (deliberate, never silent). Namespace stays `AcDream.Runtime.Platform`? +NO — rename to `AcDream.Platform` and fix the eight usings (clean naming beats +avoiding a mechanical edit). Register new projects in `AcDream.slnx`. + +**Acceptance:** build + full test suite green; guard test asserts the new +exact reference set; launcher-side consumability proven by the LA3 project +referencing only `AcDream.Platform`. + +## LA1 — launch contract (client side) + +Three pieces, one slice, because they share the session-config/status seam: + +1. **App `--session-config `:** parsed once in `Program.cs` into + `RuntimeOptions` (code-structure rule 4); carries endpoint, account, + optional character selector, `Plugins`, `LoginCommands`, `Content` + (DatDirectory/PreparedAssetPath), status-file path, credential reference. + Recon: `Program.cs` has NO subcommand dispatch today — args handling is + one positional DAT-dir (`Program.cs:35`), so the flag is purely additive + (preserve the positional arg). The live-credential seam is a single call + site (`SessionPlayerComposition.cs:1128-1135` → + `LiveSessionConnectOptions`); the config path populates the same + `RuntimeOptions` fields from a different source. Env-var dev flow + untouched. App gains the `StandardInput` credential read (mirroring + `HeadlessCredentialResolver.ResolveStandardInput` — one line, immediately + wrapped in an erasable secret, redacted `ToString`; today + `RuntimeOptions.LivePass` is a bare string — the config path must not + widen that exposure). +2. **Status stream both hosts:** per-session `status.jsonl` (path given in + config; absent → no writer constructed, zero cost). Versioned event + vocabulary (`"v":1`): `started`, `connected`, `characterList`, + `enteredWorld`, `pluginLoaded`/`pluginFailed`, `disconnected`, `exited`. + Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL + sink with four kinds (lifecycle/failure/event/resources) and NO per-session + file — the status writer is a second, separate sink, not a rework of the + diagnostics writer. App has no structured writer today; it gets the same + shared implementation (lands in Runtime so both hosts borrow it). +3. **Roster plumbing:** `CharacterList.Parsed` is consumed inside + `LiveSessionController.StartCore` (`LiveSessionController.cs:612`) and + never escapes — add a typed roster report on the lifecycle-host seam + (`ILiveSessionLifecycleHost`) so hosts can emit the `characterList` status + event and (later) the char-select screen can populate. No behavior change + to selection itself in this slice. + +**Acceptance:** round-trip tests (config → `RuntimeOptions`; stdin credential; +status events in order with exact shapes; roster surfaced); App/Headless/ +Runtime suites green; redaction test proves the password never appears in +status/diagnostics output. + +## LA2 — headless probe mode + `idle` policy + +Recon facts: the probe's shape already exists as the `NoCharacters` early-exit +(`LiveSessionController.cs:613-622` → `StopCore()` → 4-stage +`SessionScope.DrainTeardown`, graceful, `_inWorld == false` so no pre-logoff +flush) — but it fires only on selection FAILURE and maps to exit code 5 +(`HeadlessProcessHost.RunOnUpdateThread:203-212` treats any non-`Connected` +start as `ConnectionError`). + +1. **Probe:** a `Probe` flag on the connect options short-circuits `StartCore` + right after `GetCharacters` (before `TrySelectCharacter`): report roster, + `StopCore()`, return a NEW `LiveSessionStartStatus.ProbeComplete`. + `HeadlessProcessHost` maps it to exit code 0 with a final `characterList` + + `exited(reason: "probe")` status pair. Config: `mode: "probe"` on the + session descriptor relaxes the `JsonRequired` character selector + policy + for probe sessions ONLY (loader keeps strict validation otherwise — + recon: violations currently surface as raw `JsonException` → exit 3; probe + relaxation must be shape-level in the loader, not attribute removal). +2. **`idle` policy:** new consumer `HeadlessBotPolicy` id — enter world, run + plugins/login-commands (arrive in LA5/LA6), stay until stopped, clean + SIGINT teardown (K4's graceful-logout path already proves the mechanism). + +**Acceptance:** probe test (fixture session → roster event → graceful teardown +receipt → exit 0, no `EnterWorld` on the wire); loader tests for probe-shape +relaxation + strict normal validation; idle-policy lifecycle test; suites +green. Connected verification (user gate, LA11 batch): live probe against ACE +twice in a row with no lingering session (spec §11.9). + +## LA3 — `AcDream.Launcher.Core` + +New BCL-only project + `tests/AcDream.Launcher.Core.Tests/`. References +`AcDream.Platform` ONLY. + +- Profile store: `launcher-profiles.json` (spec §5 schema) — load/save/ + validate, full CRUD operations, roster merge (fold `characterList` events + in, preserving per-character user settings), 0600 on Linux. +- Session-config composition: profile + install records → the LA1 config + shape (typed writer; probe shape included). Passwords excluded — stdin only. +- Process orchestration: spawn App/Headless per launch mode, feed password to + child stdin then close, supervise lifetime, tail `status.jsonl` + (share-tolerant reads), surface typed session state. +- SHA-256 utility (pak record + download verify — consumed by LA9/LA10). + +**Acceptance:** CRUD/round-trip/merge tests; composition tests (all three +modes + probe); supervision tests against a fake child process (echo script); +status-tail tests including partial-line handling; suites green. + +## LA4 — `AcDream.Launcher` (Avalonia) + +New Avalonia project (Windows + Linux). MVVM over Launcher.Core; no game +solution references beyond `AcDream.Platform` transitively. + +- Views: server list → accounts → characters tree; add/edit/remove dialogs + for servers (name/host/port) and accounts (account + password entry); + per-character settings editor (launch mode, plugin set, login commands); + per-account "refresh characters" (probe); running-sessions status column. +- Launch actions per mode (`gui` / `guiSelect` / `headless`); probe disabled + while the launcher runs a session for that account. +- First-run wizard shell + update prompt shell (bodies land in LA9/LA10). + +**Acceptance:** ViewModel tests in Launcher.Core.Tests patterns (VMs live in +the Avalonia project but stay logic-thin; anything testable pushes down); +build green on Windows; `linux-x64` publish compiles. Visual polish is gated +at LA11 (user). + +## LA5 — plugin hosting on both hosts + +Recon facts (2026-08-14): `PluginLoader`/`PluginDiscovery`/`PluginManifest` +already live in `AcDream.Core` (Headless-reachable). App's single load loop +(`App/Program.cs:110-121`) loads ALL discovered plugins from two roots +(`AppContext.BaseDirectory/plugins` + `ApplicationPathSet.PluginsDirectory`, +dup-id skip) — no allow-list exists on either host. `AppPluginHost` is a +26-line pass-through; three of four `IPluginHost` surfaces (`State` → +`WorldGameState`, `Events` → `WorldEvents`, `Selection` → `SelectionState`) +are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is +genuinely App-only. Headless has zero plugin hosting today (confirmed). + +1. Session-config `Plugins` allow-list filters the discovery result on BOTH + hosts (absent list = load all, preserving today's dev behavior). +2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned + `State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new + capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect + headless. Contract documented in `Plugin.Abstractions`. +3. `pluginLoaded`/`pluginFailed` status events from both hosts' load loops. + +**Acceptance:** fixture plugin in Headless suite (load, capability flag, +markup no-op, teardown via collectible ALC); allow-list filter tests both +hosts; status events asserted; suites green. + +## LA6 — login commands on both hosts + +Recon facts (2026-08-14): the command core is dependency-CLEAN — +`ChatInputParser` (zero usings), `ChatCommandRouter` (BCL + +`AcDream.Core.Chat`), `RetailClientCommandCatalog` (FrozenDictionary), +`ChatVM` (Core.Chat/Combat + `System.Numerics` only), `ICommandBus` + the +four command records (BCL-only). The block is assembly identity, not +coupling. `ChatCommandRouter.Submit`'s two entanglements: a hard `ChatVM` +parameter (uses only `ShowInterfaceText`/`ShowSystemMessage`/ +`LastIncomingTellSender`/`LastOutgoingTellTarget`) and the `ICommandBus`, +whose production implementation (`LiveSessionCommandRouter`, +`App/Net/LiveSessionCommandRouter.cs`) is App-only and wraps wire-send +delegates from the live session. GUI already has a login-command analog: +`RetailUiAutomationScriptRunner` feeds `ChatCommandRouter.Submit` at +`RetailUiRuntime.cs:523-527`. + +1. **Extraction:** move parser/router/catalog + `ICommandBus` + the four + command records (+ sibling tables they require) into Runtime + (`AcDream.Runtime/Chat/...`); the router's `ChatVM` parameter becomes a + narrow feedback interface defined beside it (exactly the four members + used); `ChatVM` (stays in UI.Abstractions) implements it. GUI path stays + bit-identical — same call sites (`ChatWindowController.cs:326`, + `FloatingChatWindowController.cs:157`), same routing, CH-accepted + behavior regression-checked by the existing chat suites. +2. **Headless dispatch:** a Runtime/Headless `ICommandBus` binding the same + session send delegates (`SendTalk`/`SendTell`/`SendChannel`/ + `SendTurbineChat`) + Runtime state that App's router binds — paralleling + `LiveSessionCommandRouter`'s registrations, feedback lands in + `RuntimeCommunicationState.AddText`. +3. **Execution:** both hosts run `LoginCommands` sequentially as-if-typed + (default 500 ms inter-command delay, config-overridable) once + entered-world; per-command failures → status stream, never abort. +4. K0 guard: if the code folds into Runtime, the single-reference assertion + stands untouched; the forbidden-prefix closure tests keep passing. Any + guard text change is deliberate and documented. + +**Acceptance:** extraction lands with zero GUI chat test regressions +(UI.Abstractions + App chat suites bit-green); headless executes a +login-command script against a fixture session with ordered wire sends; +delay + failure-tolerance tests; suites green. + +## LA7 — character-select: state + wire + +Recon facts (2026-08-14): retail's screen is `gmCharacterManagementUI` +(`acclient.h:56545`) — flat listbox + Create/Enter/Delete/Restore buttons + +dialog contexts. **No 3D preview exists on retail's select screen** (the +`gmCG3DView`/`CreatureMode` viewport is chargen-only; the old +"rotating pedestal" line in `retail-ui/05-panels.md` §13 is uncited and +wrong). Our `CharacterList` parse already matches ACE's serializer exactly +(two-array shape, status/deleted always zero from ACE) and the two-phase +enter-world (0xF7C8 → 0xF7DF → 0xF657) is implemented. Missing wire: +delete/restore/error. + +1. **Wire messages** (`AcDream.Core.Net/Messages/`, retail citations in + file docs per house style): `CharacterDelete` 0xF655 — outbound + account String16L + **slot index** (`Proto_UI::SendDeleteCharacter + @0x00546b30`; NOT guid), inbound opcode-only ack followed by a fresh + CharacterList; `CharacterRestore` 0xF7D9 guid-only (ACE + holtburger + consensus; the decomp's apparent extra strings are a decompiler + artifact — spec §11.4), response 0xF643 (flag + guid + name + + secondsDisabled); `CharacterError` 0xF659 parser (new — today NO + character-stage server error can be surfaced). +2. **Runtime selection state** (J-owner pattern): roster with per-entry + greyed/pending-delete state (`SecondsGreyedOut != 0` ⇒ pending; ACE + sends a constant 1 during the grace window — treat as boolean, never a + countdown), highlight, pending-delete dialog state, typed commands + (highlight / enter / delete-request / delete-confirm / restore). + Retail behavior oracles: `RebuildCharacterList@0x004ec3a0`, + `SelectCharacter@0x004ec160`, `UpdateButtons@0x004ec240` + (Delete↔Restore swap on greyed state), `EnterGame@0x004ed440`. +3. **No-selector flow:** a graphical session config without a character + selector stops at selection state instead of auto-enter; the + first-available fallback (`CharacterList.TrySelectFirstAvailable`, + used at `LiveSessionController.cs:848-851`) remains ONLY for + selector-carrying/headless sessions. Selection feeds the existing + `EnterWorld` path unchanged. + +**Acceptance:** message round-trip tests against ACE's serializer shapes; +selection-state tests (greyed transitions, delete→list-refresh, restore, +error surfacing); no-selector stop + enter flow tests; suites green. + +## LA8 — character-select: authored retail screen + +Scope: project LA7's state through the REAL retail screen. No 3D preview +(recon-corrected; a preview would be an unapproved divergence). + +1. **Layout resolution:** retail resolves the root via + `UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039a)` + + `DBObj::GetDIDByEnum(..., 5)` — reuse OP8's ported GetDIDByEnum + machinery (category 4 precedent) for enum-table 5; slice starts by + dumping that table from installed DATs to pin the concrete DataID. + Child ids: listbox `0x1000039d`, create `0x100003a0` (present, + disabled — Create is a future campaign), enter `0x100003a2`, delete + `0x1000039f`, restore `0x1000039e`. +2. **Dialogs:** delete-confirm, please-wait, entering-world, error — the + retail dialog machinery from the OP8 WaitDialog work (`2a81e813` + mapped WaitDialog class type 0x19) is the base. +3. **Open item resolved here:** whether retail draws a render-loop + background scene behind the UI (pseudo-C proves only that the UI class + owns no viewport) — settle via user recollection + the visual gate + before polishing. + +**Acceptance:** authored screen builds from DAT assets; button-state +matrix matches `UpdateButtons` oracle (incl. Delete↔Restore swap); +enter/delete/restore/error flows drive LA7 state end-to-end; suites +green. User visual gate at LA11 (screen look, dialog flows, delete + +restore against local ACE). + +## LA9 — installer (first-run) + +- DAT locate: auto-detect `%USERPROFILE%\Documents\Asheron's Call` and + `C:\Turbine\Asheron's Call` + manual picker; validate the four DATs. +- Bake: spawn `acdream-bake --dat-dir --out /pak/acdream.pak + --threads N`. Recon: default `--out` is INSIDE the DAT dir — the launcher + always passes `--out` explicitly. Progress: add `--progress-json` to + `AcDream.Bake` (JSONL progress lines alongside the existing 5-second human + text, which stays default) — scraping human text is fragile and we own the + tool. Recon: the bake has NO whole-file SHA — after a successful bake the + LAUNCHER computes and records SHA-256 + size + `BakeToolVersion` in its + install record, and re-verifies on subsequent startups (fast corruption + check trades a few seconds of hashing for never launching against a + half-written pak). +- Install record feeds LA3's session-config composition + (DatDirectory/PreparedAssetPath). + +**Acceptance:** wizard flow tests over Launcher.Core (fake bake child emitting +`--progress-json` lines); bake-tool progress flag tests in +`tests/AcDream.Bake.Tests`; SHA record/verify tests; suites green. Connected +gate (user): clean-profile first-run against real DATs. + +## LA10 — updater + +- Manifest: GitHub Releases; `manifest.json` release asset — version, per-RID + client zip URL + SHA-256 + size, minimum-launcher version. Launcher pins + owner/repo in its config. +- Client update: poll on launch (+ manual check), download to staging, SHA + verify, unpack to `DataDirectory/app//`, atomic `current.json` + pointer swap, refuse while any session runs, keep previous version for + one-step rollback. +- Launcher self-update: staged download + rename-dance on next start. +- Session-config composition targets `app/current`'s binaries. + +**Acceptance:** manifest/download/verify/swap tests against a local HTTP +fixture; rollback test; refusal-while-running test; self-update staging test; +suites green. Connected gate (user): staged-manifest update swap end-to-end. + +## LA11 — closeout + +- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md` + covering: all three launch modes vs local ACE, probe round-trip ×2 (no + lingering session), char-select visual matrix + delete flow, login-commands + + plugin behavior on both hosts, add-server/add-account purely in UI, + clean-profile first-run wizard, staged update swap. +- Roadmap shipped-table entry, CLAUDE.md Current-state flip, memory distill, + ledger below completed, program closeout section. + +## Review protocol + +Per slice: implementer commit(s) → Opus dual-lens review (architectural + +retail-where-applicable) → fix round → narrow re-review of fixes → slice DONE +in ledger. Reviews name blast radius explicitly +(`claude-memory/feedback_blast_radius_single_lens.md`). Slices LA7/LA8 add the +retail-fidelity lens against named-retail symbols cited in the slice body; +LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. + +## Ledger + +| Slice | Status | Commits | Review | Notes | +|---|---|---|---|---| +| LA0 | — | | | | +| LA1 | — | | | | +| LA2 | — | | | | +| LA3 | — | | | | +| LA4 | — | | | | +| LA5 | — | | | | +| LA6 | — | | | | +| LA7 | — | | | | +| LA8 | — | | | | +| LA9 | — | | | | +| LA10 | — | | | | +| LA11 | — | | | | diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index 1003a54a..162778ff 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -40,7 +40,7 @@ Decided this session (2026-08-14): | Question | Decision | |---|---| | Headless launch purpose | **Run plugins** (VirindiTank-style) + login commands; same on GUI. Launcher selects which plugins per character | -| Character-select screen | **Retail screen, no Create.** 3D rotating preview + Enter World + Delete; Create Character deferred to its own campaign | +| Character-select screen | **Retail screen, no Create.** CORRECTED by 2026-08-14 recon: retail's `gmCharacterManagementUI` is a flat listbox + Enter Game / Delete / Restore buttons + dialogs — **no 3D preview exists on retail's select screen** (that machinery, `gmCG3DView`, is character-CREATION-only). We port what retail actually had; Create deferred to its own campaign | | Campaign scope | **Everything now** — launch flows + first-run install + update pipeline in one campaign | | Launcher ↔ client coupling | **Approach A: file-contract orchestrator** (config in, status events out; no game-protocol code in the launcher) | | Update feed | **GitHub Releases** (manifest.json + per-RID zips as release assets) | @@ -211,23 +211,50 @@ enters immediately. New behavior: **no character selector in options → stop at the retail character-select screen.** Selection there feeds the same `EnterWorld` path. -- **Ownership:** J-owner pattern. A Runtime-owned selection state (roster, - highlighted entry, pending-delete confirmation) with typed commands - (highlight / enter / delete-request / delete-confirm); App projects the - authored screen. Headless never uses it (config always carries a - selector; the loader already requires one). -- **UI:** imported retail screen via `LayoutImporter` (OP3/FA recipe). - The concrete LayoutDesc id and widget tree come from the - grep-named-first workflow + `docs/research/retail-ui/` during the plan; - retail decomp is the behavior oracle for list interaction, Enter World, - and Delete (including retail's delete confirmation flow — ACE serves - the character-delete message). -- **3D preview:** a void-scene render path — the selected character's - Setup + ObjDesc appearance rendered with a dedicated camera/lighting, - reusing the existing creature-appearance pipeline outside a landblock. - Own slice; the retail screen's rotation behavior is the oracle. -- **Non-goals:** Create Character (own future campaign); a login screen - ("back" exits the client — credentials always arrive via config/env). +**CORRECTED 2026-08-14 (named-retail recon):** retail's screen is +`gmCharacterManagementUI` (`acclient.h:56545`) — a `UIElement_ListBox` +character list plus Create / Enter Game / Delete / Restore buttons and +dialog contexts (delete-confirm, please-wait, entering-world, error). +**It has NO 3D preview** — the rotating-model viewport (`gmCG3DView` / +`UIElement_Viewport` / `CreatureMode`) exists only on character +CREATION's appearance/heritage/profession pages. The earlier +"3D preview on a pedestal" belief traced to one uncited line in +`docs/research/retail-ui/05-panels.md` §13. We port the real screen; a +preview would be a deliberate divergence we are NOT taking. + +- **Ownership:** J-owner pattern. A Runtime-owned selection state (roster + incl. greyed/pending-delete seconds, highlighted entry, pending-delete + confirmation) with typed commands (highlight / enter / delete-request / + delete-confirm / restore); App projects the authored screen. Headless + never uses it (config always carries a selector; the loader already + requires one). +- **UI:** imported retail screen. The root layout id is resolved + indirectly — retail calls + `UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039a)` and + resolves the concrete DataID via `DBObj::GetDIDByEnum(..., 5)` (the same + GetDIDByEnum machinery OP8 already ported for key names, category 4). + Child widget ids from the decomp: listbox `0x1000039d`, create + `0x100003a0` (hidden/no-op this campaign), enter `0x100003a2`, delete + `0x1000039f`, restore `0x1000039e`. Behavior oracles: + `RebuildCharacterList@0x004ec3a0`, `SelectCharacter@0x004ec160`, + `UpdateButtons@0x004ec240` (Delete↔Restore visibility swap on greyed + state), `EnterGame@0x004ed440`, + `MakeDeleteCharacterConfirmationDialog@0x004ecca0`. +- **Wire (new messages):** `CharacterDelete` 0xF655 (outbound: account + String16L + **slot index**, per `Proto_UI::SendDeleteCharacter@0x00546b30` + — NOT the guid; inbound: opcode-only ack, then a fresh CharacterList), + `CharacterRestore` 0xF7D9 (guid) with response 0xF643, and a + `CharacterError` 0xF659 parser (currently absent — acdream cannot + surface any character-stage server error today). Enter-world's + two-phase handshake (0xF7C8 → 0xF7DF → 0xF657) is already implemented. +- **Open items** carried to the plan: the concrete layout DataID (dump + enum-table 5 from installed DATs), and whether retail rendered any + render-loop-level background scene behind the UI (the pseudo-C only + proves the UI class owns no viewport) — both resolved in the screen + slice before the user visual gate. +- **Non-goals:** Create Character (own future campaign; the Create button + exists on the authored screen but is disabled); a login screen ("back" + exits the client — credentials always arrive via config/env). - Retail-workflow rules apply: any behavioral deviation ships with its divergence-register row in the same commit. @@ -284,7 +311,8 @@ same `EnterWorld` path. → `RuntimeOptions`, stdin credential resolution, status-event writer output shape, plugin-set narrowing, login-command execution order. - **Character-select:** Runtime selection-state tests; authored screen - via UI Studio dumps/screenshots; behavior against retail oracle. + exercised by focused App tests (UI Studio no longer exists — deleted at + Campaign V); visuals settle at the user gate. - **Headless plugin host:** fixture plugin in the Headless suite (load, capability flag, teardown). - **Connected gates (user-driven):** every launch mode against local ACE @@ -301,12 +329,22 @@ same `EnterWorld` path. after reading CH seams; do not regress CH-accepted chat behavior. 2. Current App plugin-loading behavior (what loads today, when) — read before wiring the session-driven set. -3. Retail character-select LayoutDesc id + widget tree — research task - (grep-named + `docs/research/retail-ui/`). -4. Character-delete wire message + ACE handling — verify against - holtburger/ACE before implementing the Delete flow. -5. Void-scene preview: lighting/camera parameters need the retail oracle - (what does retail actually render behind the character?). +3. RESOLVED by recon except the concrete root-layout DataID: widget tree + + behavior symbols are in §7; the DataID hides behind `GetDIDByEnum` + enum-table 5 (enum `0x10000005`) — dump the table from installed DATs + in the screen slice. +4. RESOLVED by recon: delete = 0xF655 account+slot (ack opcode-only, then + fresh CharacterList); restore = 0xF7D9 guid → 0xF643 response; ACE's + `secondsSincePendingDelete` computes to a constant 1 during the grace + window (ACE quirk — treat any non-zero as "pending delete", don't + display it as a countdown). One decomp ambiguity (whether retail's + restore carries two extra strings) is noted as a likely decompiler + artifact — ACE + holtburger agree on guid-only; follow them and note + the register row is NOT needed (wire consensus, no behavior change). +5. Whether retail rendered a render-loop-level background scene behind the + character-management UI — pseudo-C only proves the UI class owns no + viewport. Resolve in the screen slice (user memory of retail + the + visual gate settle it). 6. Bake tool 4 invocation surface from Launcher.Core (in-process reference vs child process) — child process preferred to keep Launcher.Core free of game-solution references; confirm the tool's CLI is sufficient. From 7f24af1a3715ffefea2992eade7493ef07e32361 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:17:53 +0200 Subject: [PATCH 003/138] =?UTF-8?q?docs:=20Campaign=20LA=20Linux=20posture?= =?UTF-8?q?=20=E2=80=94=20full=20launcher=20stack=20Linux-tested;=20GUI=20?= =?UTF-8?q?waits=20for=20Slice=20L?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User decision 2026-08-14: everything the launcher does ships Linux-tested in this campaign (launcher UI, install/update with manual DAT picker, headless launches with plugins + login commands, probe, per-slice Linux test runs, Linux connected-gate section at LA11). GUI client launches stay Windows-only until Slice L resumes later; the launcher renders GUI modes disabled on Linux with an explicit note, and the host-agnostic session-config contract means Slice L lights them up with no launcher changes. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 26 +++++++++++++++++++ .../2026-08-14-launcher-campaign-design.md | 1 + 2 files changed, 27 insertions(+) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 1a104e65..ecb3620b 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -51,6 +51,32 @@ Parallelism guide: LA3/LA4 (launcher side) proceed alongside LA5–LA8 (client side) — different assemblies, no shared files. LA9/LA10 close the launcher side; LA11 closes the campaign. +## Linux posture (binding — user decision 2026-08-14) + +Everything the launcher does must WORK ON LINUX in this campaign, except +GUI client launches: the Linux graphical client is Slice L, parked at L1, +resuming later ("ok we will do it later"). Concretely: + +- **Linux-shipping in LA:** the Avalonia launcher UI, profile CRUD + + 0600-permission file, installer (manual DAT picker — the auto-detect + paths are Windows-only; `acdream-bake` is GL-free and runs on Linux), + updater (staged swap; Linux can replace a running binary but keep the + same staged-atomic flow), headless launches with plugins + login + commands, and the character probe. +- **Launcher UX on Linux:** the `gui` / `guiSelect` launch modes render + disabled with an explicit "requires the Linux graphical client (Slice + L)" note — never a silent failure. +- **Per-slice enforcement:** every slice touching Launcher.Core, Headless, + Runtime, Bake, or Platform runs its test projects on Linux (native + Ubuntu or WSL, matching the K-slice practice) before the slice is DONE; + LA4/LA9/LA10 additionally prove a real `linux-x64` self-contained + publish. LA11's connected-gate script gets a Linux section: launcher on + Ubuntu doing CRUD, probe, headless launch with plugin + login commands, + first-run install with a manual DAT path, and an update swap. +- When Slice L later ships, the launcher's Linux GUI modes light up with + NO launcher changes (the session-config contract is host-agnostic) — + that expectation is part of LA's design acceptance. + ## LA0 — `AcDream.Platform` extraction New BCL-only project `src/AcDream.Platform/` holding `ApplicationPathSet` + diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index 162778ff..a68527c5 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -45,6 +45,7 @@ Decided this session (2026-08-14): | Launcher ↔ client coupling | **Approach A: file-contract orchestrator** (config in, status events out; no game-protocol code in the launcher) | | Update feed | **GitHub Releases** (manifest.json + per-RID zips as release assets) | | Profile editing | **Full CRUD in the launcher UI** — add/edit/remove servers, accounts, passwords, per-character settings. The JSON file is storage (hand-editable as a bonus), never the required interface | +| Linux scope | **Full launcher stack Linux-tested in LA** (launcher UI, install/update, headless + plugins + probe); GUI launches stay Windows-only until Slice L resumes later (user 2026-08-14). Launcher disables GUI modes on Linux with an explicit note | | Character enumeration | **On-demand probe**: launcher spawns the headless host in a probe mode (connect → `CharacterList` → status event → graceful disconnect BEFORE entering world → exit) and folds the roster into the profile store | Rejected: the launcher itself embedding Runtime/Core.Net to speak the From cb6502c8a5fc6808dfbb81f67422c2b8634e4aa9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:19:02 +0200 Subject: [PATCH 004/138] =?UTF-8?q?feat(platform):=20Campaign=20LA=20LA0?= =?UTF-8?q?=20=E2=80=94=20extract=20ApplicationPathSet=20to=20AcDream.Plat?= =?UTF-8?q?form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher (LA3/LA4) needs the XDG/Windows path contract (ApplicationPathSet/IApplicationPathEnvironment) without pulling in any gameplay assembly. Move it out of AcDream.Runtime into a new BCL-only AcDream.Platform project so the launcher-side Launcher.Core project can reference it directly per the campaign plan (docs/plans/2026-08-14-launcher-campaign.md, LA0). Namespace renamed AcDream.Runtime.Platform -> AcDream.Platform; code is otherwise byte-identical (no logic changes). AcDream.Runtime now carries a ProjectReference to AcDream.Platform and re-exports it transitively, so App and Headless keep resolving the type without a direct reference and K0's Headless single-ProjectReference guard (HeadlessAssemblyReferencesOnlyTheRuntimeProject) stands unchanged. The sibling Runtime dependency-boundary guard (RuntimeProjectDeclaresOnlyApprovedProjectDependencies) does assert Runtime's own project-reference set, so it needed a deliberate, documented addition of AcDream.Platform to its expected list. Moved tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs to a new tests/AcDream.Platform.Tests/ project (namespace AcDream.Platform.Tests) referencing only AcDream.Platform. Registered both new projects in AcDream.slnx. Co-Authored-By: Claude Fable 5 --- AcDream.slnx | 2 ++ .../Platform/GraphicalHostPlatformServices.cs | 2 +- .../GraphicalLegacyConfigurationMigrator.cs | 2 +- src/AcDream.App/Program.cs | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 2 +- .../Platform/HeadlessPathSet.cs | 2 +- .../Platform/HeadlessPlatformEnvironment.cs | 2 +- src/AcDream.Platform/AcDream.Platform.csproj | 9 ++++++++ .../ApplicationPathSet.cs | 2 +- src/AcDream.Runtime/AcDream.Runtime.csproj | 1 + ...aphicalLegacyConfigurationMigratorTests.cs | 2 +- .../AcDream.Platform.Tests.csproj | 22 +++++++++++++++++++ .../ApplicationPathSetTests.cs | 4 ++-- .../RuntimeDependencyBoundaryTests.cs | 5 +++++ 14 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 src/AcDream.Platform/AcDream.Platform.csproj rename src/{AcDream.Runtime/Platform => AcDream.Platform}/ApplicationPathSet.cs (99%) create mode 100644 tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj rename tests/{AcDream.Runtime.Tests/Platform => AcDream.Platform.Tests}/ApplicationPathSetTests.cs (98%) diff --git a/AcDream.slnx b/AcDream.slnx index d28db1d2..fa90475d 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs index ca466c6d..42d9d373 100644 --- a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs +++ b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; using AcDream.App.Rendering; -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.App.Platform; diff --git a/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs b/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs index 8a7e2fd9..b858cb50 100644 --- a/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs +++ b/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs @@ -1,4 +1,4 @@ -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.App.Platform; diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 7380feb1..3d46ce90 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -3,7 +3,7 @@ using AcDream.App.Plugins; using AcDream.App.Platform; using AcDream.App.Rendering; using AcDream.Core.Plugins; -using AcDream.Runtime.Platform; +using AcDream.Platform; using Serilog; GraphicalHostPlatformServices graphicalPlatform = diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8b0839a1..74a61be4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -8,8 +8,8 @@ using AcDream.App.Settings; using AcDream.App.Platform; using AcDream.App.World; using AcDream.Content; +using AcDream.Platform; using AcDream.Runtime; -using AcDream.Runtime.Platform; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs index 24059dae..bdbe1866 100644 --- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -1,5 +1,5 @@ using AcDream.Headless.Configuration; -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.Headless.Platform; diff --git a/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs index 220f0571..27cced4a 100644 --- a/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs +++ b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs @@ -1,4 +1,4 @@ -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.Headless.Platform; diff --git a/src/AcDream.Platform/AcDream.Platform.csproj b/src/AcDream.Platform/AcDream.Platform.csproj new file mode 100644 index 00000000..10225ce5 --- /dev/null +++ b/src/AcDream.Platform/AcDream.Platform.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + latest + true + + diff --git a/src/AcDream.Runtime/Platform/ApplicationPathSet.cs b/src/AcDream.Platform/ApplicationPathSet.cs similarity index 99% rename from src/AcDream.Runtime/Platform/ApplicationPathSet.cs rename to src/AcDream.Platform/ApplicationPathSet.cs index 6e4ee3d1..a6743ad1 100644 --- a/src/AcDream.Runtime/Platform/ApplicationPathSet.cs +++ b/src/AcDream.Platform/ApplicationPathSet.cs @@ -1,4 +1,4 @@ -namespace AcDream.Runtime.Platform; +namespace AcDream.Platform; /// /// BCL-only view of the process environment used to resolve portable diff --git a/src/AcDream.Runtime/AcDream.Runtime.csproj b/src/AcDream.Runtime/AcDream.Runtime.csproj index b1bc35a5..422184a6 100644 --- a/src/AcDream.Runtime/AcDream.Runtime.csproj +++ b/src/AcDream.Runtime/AcDream.Runtime.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/AcDream.App.Tests/Platform/GraphicalLegacyConfigurationMigratorTests.cs b/tests/AcDream.App.Tests/Platform/GraphicalLegacyConfigurationMigratorTests.cs index 1cccf33c..5889bfd2 100644 --- a/tests/AcDream.App.Tests/Platform/GraphicalLegacyConfigurationMigratorTests.cs +++ b/tests/AcDream.App.Tests/Platform/GraphicalLegacyConfigurationMigratorTests.cs @@ -1,5 +1,5 @@ using AcDream.App.Platform; -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.App.Tests.Platform; diff --git a/tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj b/tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj new file mode 100644 index 00000000..4a538c5b --- /dev/null +++ b/tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + latest + true + + + + + + + + + + + + + + diff --git a/tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs b/tests/AcDream.Platform.Tests/ApplicationPathSetTests.cs similarity index 98% rename from tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs rename to tests/AcDream.Platform.Tests/ApplicationPathSetTests.cs index bbc595c3..7b16a89d 100644 --- a/tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs +++ b/tests/AcDream.Platform.Tests/ApplicationPathSetTests.cs @@ -1,6 +1,6 @@ -using AcDream.Runtime.Platform; +using AcDream.Platform; -namespace AcDream.Runtime.Tests.Platform; +namespace AcDream.Platform.Tests; public sealed class ApplicationPathSetTests { diff --git a/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs index 262eb607..d1c65ae6 100644 --- a/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs +++ b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs @@ -72,6 +72,11 @@ public sealed class RuntimeDependencyBoundaryTests "AcDream.Content", "AcDream.Core", "AcDream.Core.Net", + // Campaign LA LA0: AcDream.Platform is the new BCL-only + // ApplicationPathSet home; Runtime re-exports it + // transitively so App/Headless keep reaching it without a + // direct reference. + "AcDream.Platform", "AcDream.Plugin.Abstractions", } .Select(projectName => Path.Combine( From a49e92df3aacc6c059b6042fab7dee8d381285de Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:30:04 +0200 Subject: [PATCH 005/138] =?UTF-8?q?fix(platform):=20Campaign=20LA=20LA0=20?= =?UTF-8?q?review=20fixes=20=E2=80=94=20CI=20Linux=20lanes,=20arch=20doc,?= =?UTF-8?q?=20self-guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of cb6502c8 passed with six findings; this lands the fix round: 1. headless-portability.yml: AcDream.Platform src/tests join both path triggers and the presentation-free build/test arrays — the moved XDG tests run on ubuntu-latest again (they had fallen out of every Linux lane). 2. acdream-architecture.md: AcDream.Platform gets its own layer block; Runtime may-reference clause updated (the guard changed in cb6502c8, its human-readable twin had not). 3. PlatformDependencyBoundaryTests: the BCL-only contract (zero project/package references) is now enforced, not just observed. 4. memory/project_linux_graphical.md canonical seam renamed. 5. Plan LA0 recon corrected: the K0 Headless guard was never the guard needing amendment (it asserts Headless own refs); Runtime own-refs guard was — the commit did the right thing, the plan text now says so. 6. App declares its AcDream.Platform reference explicitly per its own convention instead of riding transitivity. Platform.Tests: 4 passed (3 moved + the new guard). Co-Authored-By: Claude Fable 5 --- .github/workflows/headless-portability.yml | 6 ++ docs/architecture/acdream-architecture.md | 15 +++-- docs/plans/2026-08-14-launcher-campaign.md | 22 ++++--- memory/project_linux_graphical.md | 7 ++- src/AcDream.App/AcDream.App.csproj | 5 ++ .../PlatformDependencyBoundaryTests.cs | 62 +++++++++++++++++++ 6 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 6cea0898..a24ae829 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -5,6 +5,7 @@ on: paths: - ".github/workflows/headless-portability.yml" - "AcDream.slnx" + - "src/AcDream.Platform/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -13,6 +14,7 @@ on: - "src/AcDream.Headless/**" - "src/AcDream.App/**" - "src/AcDream.UI.Abstractions/**" + - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -26,6 +28,7 @@ on: paths: - ".github/workflows/headless-portability.yml" - "AcDream.slnx" + - "src/AcDream.Platform/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -34,6 +37,7 @@ on: - "src/AcDream.Headless/**" - "src/AcDream.App/**" - "src/AcDream.UI.Abstractions/**" + - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -78,6 +82,7 @@ jobs: shell: pwsh run: | $projects = @( + "src/AcDream.Platform/AcDream.Platform.csproj", "src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj", "src/AcDream.Core/AcDream.Core.csproj", "src/AcDream.Core.Net/AcDream.Core.Net.csproj", @@ -98,6 +103,7 @@ jobs: shell: pwsh run: | $projects = @( + "tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj", "tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj", "tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj", "tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj", diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2eae6a17..b74d6419 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -266,14 +266,21 @@ src/ World/ RuntimeWorldEnvironmentState.cs -> canonical calendar/time/weather owner RuntimeWorldTransitState.cs -> canonical reveal generation/readiness owner - Platform/ - ApplicationPathSet.cs -> shared BCL-only XDG/Windows config, data, - cache, plugin, screenshot, and diagnostic paths RuntimeGenerationReset.cs -> one retryable canonical-generation reset -> Slice J complete; graphical and no-window hosts share one GameRuntime - -> may reference Core, Core.Net, Content, and Plugin.Abstractions only + -> may reference Core, Core.Net, Content, Plugin.Abstractions, and + Platform only -> must never reference App, UI, Silk.NET, OpenAL, or Arch + AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0) + ApplicationPathSet.cs -> shared XDG/Windows config, data, cache, + plugin, screenshot, and diagnostic paths + -> zero project/package references (guarded by + tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); + Runtime references it and re-exports transitively to App/Headless; + the external launcher (AcDream.Launcher.Core) references ONLY this + project from the game solution + AcDream.Headless/ Linux/Windows no-window production host Program.cs -> CLI entry only Configuration/ -> strict versioned process/session config diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index ecb3620b..35728f64 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -89,12 +89,16 @@ Recon facts (2026-08-14): blast radius is the definition, six source files `App/Program.cs`, `GameWindow.cs:533`, `HeadlessPathSet.cs`, `HeadlessPlatformEnvironment.cs`; two more files are doc-comment-only), two test files (`ApplicationPathSetTests.cs` moves to a new -`tests/AcDream.Platform.Tests/` or stays keyed to the new assembly; -`GraphicalLegacyConfigurationMigratorTests.cs` fixtures), and ONE dependency -guard: `tests/AcDream.Headless.Tests/HeadlessDependencyBoundaryTests.cs` -`HeadlessAssemblyReferencesOnlyTheRuntimeProject` asserts Headless references -exactly `[AcDream.Runtime.csproj]` — amend to the exact new set in the same -commit (deliberate, never silent). Namespace stays `AcDream.Runtime.Platform`? +`tests/AcDream.Platform.Tests/`; +`GraphicalLegacyConfigurationMigratorTests.cs` fixtures), and the dependency +guards — CORRECTED post-review (the original recon here asserted the wrong +guard, the C4-closeout failure mode): the K0 Headless guard +(`HeadlessAssemblyReferencesOnlyTheRuntimeProject`) asserts HEADLESS's own +csproj reference list, which this move does not touch — it stays UNCHANGED; +the guard that actually needs amending is Runtime's own +`RuntimeDependencyBoundaryTests.RuntimeProjectDeclaresOnlyApprovedProjectDependencies` +(Runtime gains the `AcDream.Platform` reference), amended with a cited +comment in the same commit. Namespace stays `AcDream.Runtime.Platform`? NO — rename to `AcDream.Platform` and fix the eight usings (clean naming beats avoiding a mechanical edit). Register new projects in `AcDream.slnx`. @@ -400,10 +404,10 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| -| LA0 | — | | | | -| LA1 | — | | | | +| LA0 | review PASS; fix round applied | `cb6502c8` + fixes | Opus PASS w/ 6 findings 2026-08-14; narrow re-review pending | Byte-identity proven; CI Linux lanes + arch doc + Platform self-guard + App explicit ref fixed; plan recon corrected | +| LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields | | LA2 | — | | | | -| LA3 | — | | | | +| LA3 | in flight (Sonnet, isolated worktree) | | | pinned contract shared with LA1 | | LA4 | — | | | | | LA5 | — | | | | | LA6 | — | | | | diff --git a/memory/project_linux_graphical.md b/memory/project_linux_graphical.md index a104e378..df7114de 100644 --- a/memory/project_linux_graphical.md +++ b/memory/project_linux_graphical.md @@ -12,9 +12,10 @@ Linux gameplay or renderer fork. Canonical seams: -- `AcDream.Runtime.Platform.ApplicationPathSet` owns XDG/Windows config, data, - cache, plugin, screenshot, and diagnostic paths for graphical and headless - hosts. +- `AcDream.Platform.ApplicationPathSet` (moved out of Runtime by Campaign LA + LA0, 2026-08-14) owns XDG/Windows config, data, cache, plugin, screenshot, + and diagnostic paths for graphical and headless hosts — and now for the + external launcher, which references only `AcDream.Platform`. - `AcDream.App.Platform.GraphicalHostPlatformServices` owns one startup OS, architecture, RID, native-dependency manifest, path set, and pacing factory. - `PlatformFramePacingWaiterFactory` selects the existing Windows diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index c79ba665..80489ef1 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -58,6 +58,11 @@ + + diff --git a/tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs b/tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs new file mode 100644 index 00000000..8b631546 --- /dev/null +++ b/tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs @@ -0,0 +1,62 @@ +using System.Runtime.CompilerServices; +using System.Xml.Linq; + +namespace AcDream.Platform.Tests; + +// Campaign LA LA0 review finding 3: AcDream.Platform's entire premise is +// being the BCL-only assembly the external launcher can reference without +// pulling any gameplay code. That contract is what this guard enforces — +// the csproj must declare zero ProjectReference and zero PackageReference +// entries, forever, in the same spirit as Runtime's and Headless's +// dependency-boundary guards. +public sealed class PlatformDependencyBoundaryTests +{ + [Fact] + public void PlatformProjectDeclaresNoProjectOrPackageDependencies() + { + var repositoryRoot = FindRepositoryRoot(); + var projectPath = Path.Combine( + repositoryRoot, + "src", + "AcDream.Platform", + "AcDream.Platform.csproj"); + var project = XDocument.Load(projectPath); + + Assert.Empty(project.Descendants("ProjectReference")); + Assert.Empty(project.Descendants("PackageReference")); + } + + private static string FindRepositoryRoot( + [CallerFilePath] string sourcePath = "") + { + string[] starts = + { + Path.GetDirectoryName(sourcePath) ?? string.Empty, + Directory.GetCurrentDirectory(), + AppContext.BaseDirectory, + }; + foreach (string start in starts) + { + if (string.IsNullOrEmpty(start)) + { + continue; + } + + var directory = new DirectoryInfo(start); + while (directory is not null) + { + if (File.Exists(Path.Combine( + directory.FullName, + "AcDream.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException( + "Could not find AcDream.slnx above the source, working, or output directory."); + } +} From 7a839cba7130a03fb55fe47ca47f677cb2f880f8 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:32:52 +0200 Subject: [PATCH 006/138] =?UTF-8?q?docs:=20Campaign=20LA=20LA0=20DONE=20?= =?UTF-8?q?=E2=80=94=20ledger=20closed;=20arch-doc=20wording=20nit=20from?= =?UTF-8?q?=20narrow=20re-review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-review closed all six findings and flagged one docs-only nit: the Platform layer block described App as reaching Platform transitively when the same commit made the reference direct, and spoke of the launcher in the present tense. Both corrected. Co-Authored-By: Claude Fable 5 --- docs/architecture/acdream-architecture.md | 8 +++++--- docs/plans/2026-08-14-launcher-campaign.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index b74d6419..4696577b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -277,9 +277,11 @@ src/ plugin, screenshot, and diagnostic paths -> zero project/package references (guarded by tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); - Runtime references it and re-exports transitively to App/Headless; - the external launcher (AcDream.Launcher.Core) references ONLY this - project from the game solution + Runtime and App reference it directly; Headless reaches it + transitively through Runtime (K0 guard: Headless declares exactly + one project reference); the external launcher (AcDream.Launcher.Core, + Campaign LA — under construction) references ONLY this project from + the game solution AcDream.Headless/ Linux/Windows no-window production host Program.cs -> CLI entry only diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 35728f64..cf3e6476 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -404,7 +404,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| -| LA0 | review PASS; fix round applied | `cb6502c8` + fixes | Opus PASS w/ 6 findings 2026-08-14; narrow re-review pending | Byte-identity proven; CI Linux lanes + arch doc + Platform self-guard + App explicit ref fixed; plan recon corrected | +| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields | | LA2 | — | | | | | LA3 | in flight (Sonnet, isolated worktree) | | | pinned contract shared with LA1 | From 6a32f37589ebda85d8149a0a3465d37c47585d68 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:46:48 +0200 Subject: [PATCH 007/138] =?UTF-8?q?feat(net):=20Campaign=20LA=20LA7a=20?= =?UTF-8?q?=E2=80=94=20CharacterDelete/CharacterRestore/CharacterError=20w?= =?UTF-8?q?ire=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the three character-management wire messages LA7 (design spec §7, plan §11 item 4) identified as missing before the character-select screen (LA8) can be built: delete, restore, and the server error channel. Message types + tests only — no WorldSession/Runtime/UI wiring, that is LA7b. CharacterDelete (0xF655): outbound account+SLOT-INDEX request per Proto_UI::SendDeleteCharacter@0x00546b30 (retail packs the account as String16L then writes the trailing u32 directly after — NOT the character guid; CPlayerSystem::DeleteCharacter@0x0055f830 resolves that slot via CharacterSet::GetSlot before sending). The server's ack reuses the same opcode with an empty body (ACE GameMessageCharacterDelete.cs); a fresh CharacterList follows separately per CharacterHandler.cs:322 — that refresh flow is explicitly out of scope here (LA7b). CharacterRestore (0xF7D9 request / 0xF643 response): guid-only request, per ACE (CharacterHandler.cs:331-385, ReadUInt32 only) and holtburger (CharacterRestoreRequestData, guid-only) independent consensus. The decompiled call site (Proto_UI::SendAdminRestoreCharacter@0x00546cf0) appears to pack two extra strings, but its only caller (CPlayerSystem::RestoreCharacter@0x0055d760) passes an uninitialized local (`class PStringBase* edx;`, never assigned) as the second argument and `this` (a CPlayerSystem*, not a string) as the third — textbook decompiler register-corruption, not real arguments. No divergence-register row: this follows the correct reading of a corrupted decompile, not a deviation from retail (spec §11 item 4). The response reuses opcode 0xF643, a genuine retail collision with CharacterCreateResponse (ACE's own comment: "This is a duplicate...", GameMessageOpcode.cs:42); GameMessageCharacterRestore.cs always writes a success shape (flag=1 + guid + name + secondsGreyedOut), but retail's CharacterRestore handler can also reply via the CharacterCreateResponse path on failure (e.g. NameInUse) with a flag-only body and no trailing fields — the parser mirrors that conditionality instead of assuming the four fields are always present. CharacterError (0xF659): u32 error code, confirmed directly from retail's inbound dispatcher UIQueueManager::ProcessNetBlobData@0x0055b000 -> CPlayerSystem::Handle_CharacterError@0x0055d5d0, which reads `enum charError` straight off the wire. The Code enum is a verbatim port of retail's own enum charError (docs/research/named-retail/acclient.h: 4038-4067, 26 members incl. CHAR_ERROR_NUM_ERRORS) rather than a subset filtered through ACE — retail's header names four members ACE's C# CharacterError enum omits (LoggedOn, NoPremade, AccountInUse, CharacterIsBooted) because ACE's server never sends them, though a genuine retail server could. The 32-bit storage-width compiler sentinel FORCE_charError_32_BIT is deliberately excluded (not a real value). Unknown codes never throw — RawErrorCode always preserves the wire value. Today acdream cannot surface any character-stage server error; this is the first parser for the family. 46 new tests (byte-exact builder assertions, ACE-serializer-shaped parser fixtures via the existing AceWireWriter test helper, all 26 retail error codes round-tripped, unknown/truncated/wrong-opcode handling). Full Core.Net.Tests suite: 951 passed, 0 failed, 0 skipped. Release build green. Co-Authored-By: Claude Fable 5 --- .../Messages/CharacterDelete.cs | 82 ++++++ .../Messages/CharacterError.cs | 270 ++++++++++++++++++ .../Messages/CharacterRestore.cs | 140 +++++++++ .../Messages/CharacterDeleteTests.cs | 83 ++++++ .../Messages/CharacterErrorTests.cs | 113 ++++++++ .../Messages/CharacterRestoreTests.cs | 123 ++++++++ 6 files changed, 811 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/CharacterDelete.cs create mode 100644 src/AcDream.Core.Net/Messages/CharacterError.cs create mode 100644 src/AcDream.Core.Net/Messages/CharacterRestore.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterDeleteTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterErrorTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs diff --git a/src/AcDream.Core.Net/Messages/CharacterDelete.cs b/src/AcDream.Core.Net/Messages/CharacterDelete.cs new file mode 100644 index 00000000..6fa07037 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterDelete.cs @@ -0,0 +1,82 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-delete request and server acknowledgement, both riding +/// opcode 0xF655. +/// +/// +/// Wire layout ported from retail Proto_UI::SendDeleteCharacter at +/// 0x00546b30: the opcode, then AC1Legacy::PStringBase<char>::Pack +/// of the account id as a String16L, then a trailing u32 written directly +/// after the packed string (*(uint32_t*)var_4 = arg2): +/// +/// +/// +/// u32 opcode (0xF655) +/// String16L accountName +/// u32 characterSlot (NOT the character guid) +/// +/// +/// +/// The caller, CPlayerSystem::DeleteCharacter at 0x0055f830, +/// resolves that trailing u32 from the target character's guid via +/// CharacterSet::GetSlot(persistentData + 4, guid) before sending — +/// retail deletes by **account + SLOT INDEX**, never the character guid. +/// This builder takes the already-resolved slot; resolving a selected +/// character to its slot is Runtime selection-state work (Campaign LA +/// slice LA7b), not this file's job. +/// +/// +/// +/// The server's acknowledgement reuses the same opcode with no trailing +/// payload — ACE's GameMessageCharacterDelete constructs a bare +/// 4-byte body +/// (ACE.Server/Network/GameMessages/Messages/GameMessageCharacterDelete.cs, +/// base constructor called with bodyLength: 4 and no further +/// Writer.Write calls). holtburger's inbound dispatcher +/// (holtburger-protocol/src/messages/game_message/unpack.rs:50-58) +/// disambiguates request vs. ack the identical way we do here — a request +/// has bytes remaining after the opcode, the ack does not. +/// +/// +/// +/// After the ack, ACE immediately follows with a fresh +/// so the roster reflects the character's new pending-delete state +/// (CharacterHandler.CharacterDelete, +/// ACE.Server/Network/Handlers/CharacterHandler.cs:322, inside the +/// SaveCharacter success callback). Requesting and re-rendering that +/// refreshed roster belongs to LA7b's Runtime selection state — this file +/// only builds the request and recognizes the ack. +/// +/// +public static class CharacterDelete +{ + public const uint Opcode = 0xF655u; + + /// + /// Build the body bytes for an outbound CharacterDelete request. + /// Layout: opcode(4) + String16L(accountName) + characterSlot(4). + /// + public static byte[] BuildRequestBody(string accountName, uint characterSlot) + { + ArgumentNullException.ThrowIfNull(accountName); + var w = new PacketWriter(32); + w.WriteUInt32(Opcode); + w.WriteString16L(accountName); + w.WriteUInt32(characterSlot); + return w.ToArray(); + } + + /// + /// Returns whether a complete game-message body is the server's + /// delete acknowledgement — the canonical four-byte opcode-only form + /// ACE emits. A fresh follows separately + /// and is not this method's concern. + /// + public static bool IsAcknowledgement(ReadOnlySpan body) => + body.Length == sizeof(uint) && + BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode; +} diff --git a/src/AcDream.Core.Net/Messages/CharacterError.cs b/src/AcDream.Core.Net/Messages/CharacterError.cs new file mode 100644 index 00000000..7d2c70ce --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterError.cs @@ -0,0 +1,270 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound CharacterError GameMessage (opcode 0xF659) — the +/// server's catch-all failure notice during the pre-world character-select +/// stage (logon conflicts, delete/restore failures, enter-world rejections, +/// subscription state). Today acdream cannot surface ANY character-stage +/// server error to the user; this is the first parser for the family. +/// +/// +/// Wire layout confirmed directly from retail's inbound dispatcher, +/// UIQueueManager::ProcessNetBlobData at 0x0055b000, which +/// reads a u32 immediately after the opcode and passes it to +/// CPlayerSystem::Handle_CharacterError at 0x0055d5d0 typed +/// as enum charError (enum charError eax_86 = *(uint32_t*)((char*)ecx + 4);): +/// +/// +/// +/// u32 opcode (0xF659) +/// u32 errorCode (enum charError) +/// +/// +/// +/// ACE agrees: GameMessageCharacterError +/// (ACE.Server/Network/GameMessages/Messages/GameMessageCharacterError.cs) +/// writes exactly opcode + (uint)error, and every +/// session.SendCharacterError(...) call site in +/// CharacterHandler.cs (the two this slice's +/// / handlers can raise — +/// CharacterError.Delete, CharacterError.LogonServerFull, +/// CharacterError.EnterGameCouldntPlaceCharacter, +/// CharacterError.EnterGameCharacterNotOwned — plus every other +/// value the wider character-stage flow can raise) goes through this same +/// shape. +/// +/// +/// +/// is a verbatim port of retail's enum charError +/// (docs/research/named-retail/acclient.h:4038-4067) — the header's +/// own numeric ground truth, not a subset filtered through ACE's C# port. +/// It is a strict superset of ACE's ACE.Server.Network.Enum.CharacterError +/// (references/ACE/Source/ACE.Server/Network/Enum/CharacterError.cs): +/// retail additionally names 0x2 (LoggedOn), 0x7 (NoPremade), +/// 0x8 (AccountInUse), and 0x16 (CharacterIsBooted), none of +/// which ACE's server ever sends but all of which retail's client can +/// receive from a genuine retail server — per the project's +/// property-enum-divergence lesson, we port the complete oracle, not just +/// what today's one server implementation emits. ACE's per-value doc +/// comments (themselves sourced from the client's ID_CHAR_ERROR_* +/// string table) are folded in below where they exist. One retail member, +/// FORCE_charError_32_BIT = 0x7FFFFFFF, is a compiler +/// storage-width pragma (MSVC's "force this enum to 32-bit backing store" +/// idiom) and not a real wire value — it is deliberately NOT ported. +/// +/// +/// +/// Unknown values are never rejected: +/// always carries the wire value verbatim, and casting it to +/// (see ) can never throw in +/// C# even for a value retail itself never defined — future server +/// revisions or private servers may add codes we haven't named yet. +/// +/// +public static class CharacterError +{ + public const uint Opcode = 0xF659u; + + /// + /// Verbatim port of retail's enum charError + /// (acclient.h:4038-4067), excluding the 32-bit storage-width + /// sentinel FORCE_charError_32_BIT. + /// + public enum Code : uint + { + /// 0x00 — CHAR_ERROR_UNDEF. + Undefined = 0x00, + + /// + /// 0x01 — CHAR_ERROR_LOGON. ACE: "Cannot have two accounts logged + /// on at the same time." + /// + Logon = 0x01, + + /// 0x02 — CHAR_ERROR_LOGGED_ON. Retail-only; no ACE member. + LoggedOn = 0x02, + + /// + /// 0x03 — CHAR_ERROR_ACCOUNT_LOGON. ACE: "Server could not access + /// your account information. Please try again in a few minutes." + /// + AccountLogon = 0x03, + + /// + /// 0x04 — CHAR_ERROR_SERVER_CRASH. ACE: "The server has + /// disconnected. Please try again in a few minutes." + /// + ServerCrash = 0x04, + + /// 0x05 — CHAR_ERROR_LOGOFF. ACE: "Server could not log off your character." + Logoff = 0x05, + + /// + /// 0x06 — CHAR_ERROR_DELETE. ACE: "Server could not delete your + /// character." Sent by 's + /// server-side handler on every rejection path. + /// + Delete = 0x06, + + /// 0x07 — CHAR_ERROR_NO_PREMADE. Retail-only; no ACE member. + NoPremade = 0x07, + + /// 0x08 — CHAR_ERROR_ACCOUNT_IN_USE. Retail-only; no ACE member. + AccountInUse = 0x08, + + /// + /// 0x09 — CHAR_ERROR_ACCOUNT_INVALID. ACE: "The account name you + /// specified was not valid." + /// + AccountInvalid = 0x09, + + /// + /// 0x0A — CHAR_ERROR_ACCOUNT_DOESNT_EXIST. ACE: "The account you + /// specified doesn't exist." + /// + AccountDoesntExist = 0x0A, + + /// + /// 0x0B — CHAR_ERROR_ENTER_GAME_GENERIC. ACE: forces the player + /// back to character-select if in 3D mode; otherwise a no-op OK + /// popup. + /// + EnterGameGeneric = 0x0B, + + /// + /// 0x0C — CHAR_ERROR_ENTER_GAME_STRESS_ACCOUNT. ACE: "You cannot + /// enter the game with a stress creating character." + /// + EnterGameStressAccount = 0x0C, + + /// + /// 0x0D — CHAR_ERROR_ENTER_GAME_CHARACTER_IN_WORLD. ACE: "One of + /// your characters is still in the world. Please try again in a + /// few minutes." + /// + EnterGameCharacterInWorld = 0x0D, + + /// + /// 0x0E — CHAR_ERROR_ENTER_GAME_PLAYER_ACCOUNT_MISSING. ACE: + /// "Server unable to find player account. Please try again + /// later." + /// + EnterGamePlayerAccountMissing = 0x0E, + + /// + /// 0x0F — CHAR_ERROR_ENTER_GAME_CHARACTER_NOT_OWNED. ACE: "You do + /// not own this character." Sent by + /// 's + /// server-side handler when the delete grace window has expired. + /// + EnterGameCharacterNotOwned = 0x0F, + + /// + /// 0x10 — CHAR_ERROR_ENTER_GAME_CHARACTER_IN_WORLD_SERVER. ACE: + /// "One of your characters is currently in the world. Please try + /// again later. This is likely an internal server error." + /// + EnterGameCharacterInWorldServer = 0x10, + + /// + /// 0x11 — CHAR_ERROR_ENTER_GAME_OLD_CHARACTER. ACE: forces the + /// player back to character-select if in 3D mode; no-op + /// otherwise. + /// + EnterGameOldCharacter = 0x11, + + /// + /// 0x12 — CHAR_ERROR_ENTER_GAME_CORRUPT_CHARACTER. ACE: "This + /// character's data has been corrupted. Please delete it and + /// create a new character." + /// + EnterGameCorruptCharacter = 0x12, + + /// + /// 0x13 — CHAR_ERROR_ENTER_GAME_START_SERVER_DOWN. ACE: "This + /// character's starting server is experiencing difficulties. + /// Please try again in a few minutes." + /// + EnterGameStartServerDown = 0x13, + + /// + /// 0x14 — CHAR_ERROR_ENTER_GAME_COULDNT_PLACE_CHARACTER. ACE: + /// "This character couldn't be placed in the world right now. + /// Please try again in a few minutes." Sent by + /// 's + /// server-side handler during a shutdown-in-progress race. + /// + EnterGameCouldntPlaceCharacter = 0x14, + + /// + /// 0x15 — CHAR_ERROR_LOGON_SERVER_FULL. ACE: "Sorry, but the + /// Asheron's Call server is full currently. Please try again + /// later." Sent by both + /// and + /// 's + /// server-side handlers when the world is closed to non-advocates. + /// + LogonServerFull = 0x15, + + /// 0x16 — CHAR_ERROR_CHARACTER_IS_BOOTED. Retail-only; no ACE member. + CharacterIsBooted = 0x16, + + /// + /// 0x17 — CHAR_ERROR_ENTER_GAME_CHARACTER_LOCKED. ACE: "A save of + /// this character is still in progress. Please try again later." + /// + EnterGameCharacterLocked = 0x17, + + /// + /// 0x18 — CHAR_ERROR_SUBSCRIPTION_EXPIRED. ACE: "Your + /// subscription to this game has expired." + /// + SubscriptionExpired = 0x18, + + /// + /// 0x19 — CHAR_ERROR_NUM_ERRORS. Retail's own count-of-errors + /// sentinel (the array-bound idiom, one past the last real code) — + /// never sent on the wire as an actual error. Kept for verbatim + /// completeness of the enum range; do not treat a received 0x19 + /// as meaningful. + /// + NumErrors = 0x19, + } + + public readonly record struct Parsed(uint RawErrorCode) + { + /// + /// Best-effort named view of . A plain + /// enum cast never throws in C#, so this is safe even for values + /// retail never defined — always trust + /// as the source of truth. + /// + public Code AsCode => (Code)RawErrorCode; + } + + /// + /// Parse a CharacterError body. must start + /// with the 4-byte opcode (0xF659). + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != Opcode) + throw new FormatException($"expected CharacterError opcode 0x{Opcode:X4}, got 0x{opcode:X8}"); + + uint errorCode = ReadU32(body, ref pos); + return new Parsed(errorCode); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/src/AcDream.Core.Net/Messages/CharacterRestore.cs b/src/AcDream.Core.Net/Messages/CharacterRestore.cs new file mode 100644 index 00000000..a40858e2 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterRestore.cs @@ -0,0 +1,140 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-restore request (opcode 0xF7D9) and its response +/// (opcode 0xF643). +/// +/// +/// Request — guid-only, by reference consensus. The decompiled call +/// site (Proto_UI::SendAdminRestoreCharacter at 0x00546cf0, +/// declared with three parameters — a u32 and two PStringBase<char> +/// pointers — and packing two strings after the u32) LOOKS like it sends +/// guid + two strings. It does not: its only real caller, +/// CPlayerSystem::RestoreCharacter at 0x0055d760, declares +/// class PStringBase<char>* edx; as a local and passes it +/// straight through UNINITIALIZED as the second argument, and passes +/// this (a CPlayerSystem*, not a string) as the third. Both +/// are textbook decompiler register-corruption artifacts (uninitialized +/// register reuse + a mistyped extra parameter from an over-declared +/// callee signature), not real arguments the real call site ever +/// supplied. ACE +/// (CharacterHandler.CharacterRestore, +/// ACE.Server/Network/Handlers/CharacterHandler.cs:331-385, reads +/// only ReadUInt32()) and holtburger +/// (holtburger-protocol/src/messages/character/types.rs::CharacterRestoreRequestData, +/// guid-only) independently agree on guid-only. We follow the two +/// independent, uncorrupted references (design spec §11 item 4 — wire +/// consensus, no divergence-register row needed: this isn't a deviation +/// from retail, it's picking the correct reading of a corrupted decompile). +/// +/// +/// +/// u32 opcode (0xF7D9) +/// u32 characterGuid +/// +/// +/// +/// Response — opcode collision with CharacterCreateResponse. ACE's +/// own GameMessageOpcode.cs declares both +/// CharacterCreateResponse = 0xF643 and +/// CharacterRestoreResponse = 0xF643, // This is a duplicate... — a +/// genuine retail opcode reuse, not an ACE bug. GameMessageCharacterRestore +/// (ACE.Server/Network/GameMessages/Messages/GameMessageCharacterRestore.cs) +/// unconditionally writes a success shape: +/// +/// +/// +/// u32 opcode (0xF643) +/// u32 verificationFlag (1 = Ok, matching CharacterGenerationVerificationResponse.Ok) +/// u32 characterGuid +/// String16L characterName +/// u32 secondsGreyedOut +/// +/// +/// +/// But retail's CharacterRestore handler can ALSO reply on this same +/// opcode via the character-CREATE response path when restore itself fails +/// (e.g. SendCharacterCreateResponse(session, CharacterGenerationVerificationResponse.NameInUse) +/// when the freed name collides) — that shape is flag-only, with NO +/// trailing fields (GameMessageCharacterCreateResponse.cs: the guid / +/// name / trailing u32 are only written if (response == ... .Ok)). +/// mirrors that conditionality: the trailing three +/// fields are read only when verificationFlag == 1. Because the two +/// message families are wire-identical when they collide, a caller cannot +/// tell "restore response" from "create response" by opcode or shape +/// alone — it must track which outbound request (this file's +/// vs. a future CharacterCreate) it is +/// awaiting a reply to. Character creation is out of this campaign's scope +/// (design spec §7 non-goals); this type does not attempt to disambiguate +/// the two families itself. +/// +/// +public static class CharacterRestore +{ + public const uint RequestOpcode = 0xF7D9u; + public const uint ResponseOpcode = 0xF643u; + + /// + /// Restore response body. , , and + /// are only populated when + /// equals 1 (Ok) — retail omits them + /// entirely on the wire otherwise (see the collision note above). + /// + public readonly record struct Parsed( + uint VerificationFlag, + uint? Guid, + string? Name, + uint? SecondsGreyedOut) + { + /// True when the trailing character fields are present. + public bool IsOk => VerificationFlag == 1u; + } + + /// + /// Build the body bytes for an outbound CharacterRestore request. + /// Layout: opcode(4) + characterGuid(4). Guid-only — see the class doc + /// comment for why the decompiled call site's apparent extra strings + /// are not real. + /// + public static byte[] BuildRequestBody(uint characterGuid) + { + var w = new PacketWriter(8); + w.WriteUInt32(RequestOpcode); + w.WriteUInt32(characterGuid); + return w.ToArray(); + } + + /// + /// Parse a CharacterRestore response body (opcode 0xF643). + /// must start with the 4-byte opcode. + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != ResponseOpcode) + throw new FormatException($"expected CharacterRestore response opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}"); + + uint verificationFlag = ReadU32(body, ref pos); + if (verificationFlag != 1u) + return new Parsed(verificationFlag, null, null, null); + + uint guid = ReadU32(body, ref pos); + string name = StringReader.ReadString16L(body, ref pos); + uint secondsGreyedOut = ReadU32(body, ref pos); + + return new Parsed(verificationFlag, guid, name, secondsGreyedOut); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterDeleteTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterDeleteTests.cs new file mode 100644 index 00000000..d1fed597 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterDeleteTests.cs @@ -0,0 +1,83 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CharacterDeleteTests +{ + [Fact] + public void BuildRequestBody_Layout_OpcodeThenAccountThenSlot() + { + byte[] body = CharacterDelete.BuildRequestBody("testaccount", characterSlot: 3); + + int pos = 0; + Assert.Equal(CharacterDelete.Opcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; + + // String16L("testaccount") = u16(11) + 11 ASCII bytes, padded to a + // 4-byte boundary counted from the length prefix: 2 + 11 = 13 -> 16 + // (3 pad bytes). + ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos)); + Assert.Equal(11, len); pos += 2; + string name = System.Text.Encoding.ASCII.GetString(body.AsSpan(pos, 11)); + Assert.Equal("testaccount", name); pos += 11; + Assert.Equal(0, body[pos++]); + Assert.Equal(0, body[pos++]); + Assert.Equal(0, body[pos++]); + + uint slot = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4; + Assert.Equal(3u, slot); + + Assert.Equal(4 + 16 + 4, body.Length); // opcode + padded string + slot + Assert.Equal(pos, body.Length); + } + + [Fact] + public void BuildRequestBody_ExactByteSequence_ShortAccount() + { + // "ab" -> String16L = u16(2) + 2 bytes = 4, already 4-byte aligned, + // no padding. + byte[] body = CharacterDelete.BuildRequestBody("ab", characterSlot: 0x11u); + + byte[] expected = + [ + 0x55, 0xF6, 0x00, 0x00, // opcode 0xF655 LE + 0x02, 0x00, // String16L length = 2 + (byte)'a', (byte)'b', // string bytes + 0x11, 0x00, 0x00, 0x00, // characterSlot = 0x11 LE + ]; + + Assert.Equal(expected, body); + } + + [Fact] + public void BuildRequestBody_NullAccountName_Throws() + { + Assert.Throws( + () => CharacterDelete.BuildRequestBody(null!, characterSlot: 0)); + } + + [Fact] + public void IsAcknowledgement_AcceptsOpcodeOnlyBody() + { + byte[] body = BitConverter.GetBytes(CharacterDelete.Opcode); + + Assert.True(CharacterDelete.IsAcknowledgement(body)); + } + + [Fact] + public void IsAcknowledgement_RejectsRequestShapedBody() + { + byte[] request = CharacterDelete.BuildRequestBody("acct", characterSlot: 1); + + Assert.False(CharacterDelete.IsAcknowledgement(request)); + } + + [Fact] + public void IsAcknowledgement_RejectsTruncatedOrDifferentOpcode() + { + Assert.False(CharacterDelete.IsAcknowledgement([0x55, 0xF6, 0x00])); + Assert.False(CharacterDelete.IsAcknowledgement(BitConverter.GetBytes(0xF656u))); + Assert.False(CharacterDelete.IsAcknowledgement([])); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterErrorTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterErrorTests.cs new file mode 100644 index 00000000..b738378f --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterErrorTests.cs @@ -0,0 +1,113 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CharacterErrorTests +{ + [Theory] + [InlineData(0x00u, CharacterError.Code.Undefined)] + [InlineData(0x01u, CharacterError.Code.Logon)] + [InlineData(0x02u, CharacterError.Code.LoggedOn)] + [InlineData(0x03u, CharacterError.Code.AccountLogon)] + [InlineData(0x04u, CharacterError.Code.ServerCrash)] + [InlineData(0x05u, CharacterError.Code.Logoff)] + [InlineData(0x06u, CharacterError.Code.Delete)] + [InlineData(0x07u, CharacterError.Code.NoPremade)] + [InlineData(0x08u, CharacterError.Code.AccountInUse)] + [InlineData(0x09u, CharacterError.Code.AccountInvalid)] + [InlineData(0x0Au, CharacterError.Code.AccountDoesntExist)] + [InlineData(0x0Bu, CharacterError.Code.EnterGameGeneric)] + [InlineData(0x0Cu, CharacterError.Code.EnterGameStressAccount)] + [InlineData(0x0Du, CharacterError.Code.EnterGameCharacterInWorld)] + [InlineData(0x0Eu, CharacterError.Code.EnterGamePlayerAccountMissing)] + [InlineData(0x0Fu, CharacterError.Code.EnterGameCharacterNotOwned)] + [InlineData(0x10u, CharacterError.Code.EnterGameCharacterInWorldServer)] + [InlineData(0x11u, CharacterError.Code.EnterGameOldCharacter)] + [InlineData(0x12u, CharacterError.Code.EnterGameCorruptCharacter)] + [InlineData(0x13u, CharacterError.Code.EnterGameStartServerDown)] + [InlineData(0x14u, CharacterError.Code.EnterGameCouldntPlaceCharacter)] + [InlineData(0x15u, CharacterError.Code.LogonServerFull)] + [InlineData(0x16u, CharacterError.Code.CharacterIsBooted)] + [InlineData(0x17u, CharacterError.Code.EnterGameCharacterLocked)] + [InlineData(0x18u, CharacterError.Code.SubscriptionExpired)] + [InlineData(0x19u, CharacterError.Code.NumErrors)] + public void Parse_EveryRetailCode_RoundTripsRawAndNamedValue(uint raw, CharacterError.Code expected) + { + var w = AceWireWriter.GameMessage(CharacterError.Opcode).Write(raw); + + CharacterError.Parsed parsed = CharacterError.Parse(w.ToArray()); + + Assert.Equal(raw, parsed.RawErrorCode); + Assert.Equal(expected, parsed.AsCode); + Assert.Equal((uint)expected, raw); + } + + [Fact] + public void Parse_UnknownErrorCode_DoesNotThrow_PreservesRawValue() + { + // A value retail never defined (and well past CHAR_ERROR_NUM_ERRORS) + // — a future server revision or a private server could still send + // it. Must not throw; the raw wire value is the source of truth. + var w = AceWireWriter.GameMessage(CharacterError.Opcode).Write(0xDEADBEEFu); + + CharacterError.Parsed parsed = CharacterError.Parse(w.ToArray()); + + Assert.Equal(0xDEADBEEFu, parsed.RawErrorCode); + Assert.Equal((CharacterError.Code)0xDEADBEEFu, parsed.AsCode); + } + + [Fact] + public void Parse_MaxUintErrorCode_DoesNotThrow() + { + var w = AceWireWriter.GameMessage(CharacterError.Opcode).Write(uint.MaxValue); + + CharacterError.Parsed parsed = CharacterError.Parse(w.ToArray()); + + Assert.Equal(uint.MaxValue, parsed.RawErrorCode); + } + + [Fact] + public void Parse_ExactByteSequence_MatchesAceSerializer() + { + // ACE's GameMessageCharacterError: opcode then Writer.Write((uint)error). + byte[] body = AceWireWriter.GameMessage(CharacterError.Opcode) + .Write((uint)CharacterError.Code.Delete) + .ToArray(); + + byte[] expected = + [ + 0x59, 0xF6, 0x00, 0x00, // opcode 0xF659 LE + 0x06, 0x00, 0x00, 0x00, // CHAR_ERROR_DELETE = 6 LE + ]; + + Assert.Equal(expected, body); + + CharacterError.Parsed parsed = CharacterError.Parse(body); + Assert.Equal(CharacterError.Code.Delete, parsed.AsCode); + } + + [Fact] + public void Parse_WrongOpcode_Throws() + { + byte[] bytes = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu); + + Assert.Throws(() => CharacterError.Parse(bytes)); + } + + [Fact] + public void Parse_Truncated_Throws() + { + byte[] bytes = new byte[4]; // just the opcode, missing the error code + BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterError.Opcode); + + Assert.Throws(() => CharacterError.Parse(bytes)); + } + + [Fact] + public void Parse_EmptyBody_Throws() + { + Assert.Throws(() => CharacterError.Parse([])); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs new file mode 100644 index 00000000..432b425c --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs @@ -0,0 +1,123 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CharacterRestoreTests +{ + [Fact] + public void BuildRequestBody_ExactByteSequence_OpcodeThenGuidOnly() + { + byte[] body = CharacterRestore.BuildRequestBody(0x50000001u); + + byte[] expected = + [ + 0xD9, 0xF7, 0x00, 0x00, // opcode 0xF7D9 LE + 0x01, 0x00, 0x00, 0x50, // guid 0x50000001 LE + ]; + + Assert.Equal(expected, body); + Assert.Equal(8, body.Length); + } + + [Fact] + public void Parse_SuccessResponse_PopulatesAllTrailingFields() + { + // Mirrors ACE's GameMessageCharacterRestore: opcode, flag=1 (Ok), + // guid, String16L name, secondsGreyedOut. + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode) + .Write(1u) + .WriteGuid(0x50000002u) + .WriteString16L("+Acdream") + .Write(0u); + + CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray()); + + Assert.Equal(1u, parsed.VerificationFlag); + Assert.True(parsed.IsOk); + Assert.Equal(0x50000002u, parsed.Guid); + Assert.Equal("+Acdream", parsed.Name); + Assert.Equal(0u, parsed.SecondsGreyedOut); + } + + [Fact] + public void Parse_SuccessResponse_NonzeroSecondsGreyedOutPreserved() + { + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode) + .Write(1u) + .WriteGuid(0x50000003u) + .WriteString16L("Restored") + .Write(45u); + + CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray()); + + Assert.Equal(45u, parsed.SecondsGreyedOut); + } + + [Fact] + public void Parse_FailureShapedResponse_LeavesTrailingFieldsNull() + { + // Retail's colliding CharacterCreateResponse shape: a non-Ok flag + // (here 3 = NameInUse) has NO trailing guid/name/seconds on the + // wire at all — GameMessageCharacterCreateResponse.cs only writes + // them "if (response == ... .Ok)". Parse must not try to read past + // the flag in this case. + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode) + .Write(3u); // CharacterGenerationVerificationResponse.NameInUse + + CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray()); + + Assert.Equal(3u, parsed.VerificationFlag); + Assert.False(parsed.IsOk); + Assert.Null(parsed.Guid); + Assert.Null(parsed.Name); + Assert.Null(parsed.SecondsGreyedOut); + } + + [Fact] + public void Parse_WrongOpcode_Throws() + { + byte[] bytes = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu); + + Assert.Throws(() => CharacterRestore.Parse(bytes)); + } + + [Fact] + public void Parse_TruncatedAfterFlag_Throws() + { + // Claims success (flag=1) but the body ends before the guid. + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode).Write(1u); + + Assert.Throws(() => CharacterRestore.Parse(w.ToArray())); + } + + [Fact] + public void Parse_TruncatedBeforeFlag_Throws() + { + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode); + + Assert.Throws(() => CharacterRestore.Parse(w.ToArray())); + } + + [Fact] + public void RequestThenResponse_RoundTrips_GuidIdentity() + { + const uint guid = 0x50000009u; + byte[] request = CharacterRestore.BuildRequestBody(guid); + + // The request itself carries only the guid; re-derive it the same + // way a caller would to confirm nothing was lost in the builder. + uint requestedGuid = BinaryPrimitives.ReadUInt32LittleEndian(request.AsSpan(4)); + Assert.Equal(guid, requestedGuid); + + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode) + .Write(1u) + .WriteGuid(guid) + .WriteString16L("RoundTrip") + .Write(0u); + CharacterRestore.Parsed response = CharacterRestore.Parse(w.ToArray()); + + Assert.Equal(requestedGuid, response.Guid); + } +} From 37d74e44029fd1b7159492077a7ae525511b4dcd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:49:13 +0200 Subject: [PATCH 008/138] =?UTF-8?q?feat(launcher):=20Campaign=20LA=20LA3?= =?UTF-8?q?=20=E2=80=94=20AcDream.Launcher.Core=20profile=20store,=20compo?= =?UTF-8?q?ser,=20supervisor,=20status=20tailer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AcDream.Launcher.Core (BCL-only, ProjectReference: AcDream.Platform ONLY) plus tests/AcDream.Launcher.Core.Tests, both registered in AcDream.slnx. This is the file-contract orchestrator core the Avalonia launcher (LA4) will bind to — the game solution (Core/Runtime/App/ Headless) stays entirely out of this dependency graph, so the launcher can never accidentally grow a game-protocol coupling. - Profiles/: LauncherProfileStore owns launcher-profiles.json (spec §5 schema: version 1, servers[]/accounts[]/characters[]), strict camelCase System.Text.Json (UnmappedMemberHandling.Disallow), typed CRUD (add/edit/remove server; add/edit/remove account; edit character settings), and MergeRoster (fold a reported roster into an account's characters[] while preserving user-owned launchMode/plugins/ loginCommands, adding new rows with default guiSelect, and retaining rows absent from the roster — they may be pending-delete). 0600 on Linux via File.SetUnixFileMode after save. - Launching/: SessionConfigComposer builds the pinned session-config contract (Headless K1 shape + plugins/loginCommands/ loginCommandDelayMs/statusFile) from a profile character + install record — character selector omitted entirely for guiSelect, policy {id:"idle"} only for headless, credential always standardInput/ session. Passwords never enter this document (proven by a dedicated test). LauncherProcessSupervisor spawns a host, feeds the password to stdin then closes it, and exposes Starting/Running/Exited lifecycle; Stop calls CloseMainWindow falling back to Kill after a timeout, both reachable through an injectable ILauncherChildProcess/factory seam so the state machine is unit-testable without real OS process timing. - Status/: StatusEventParser decodes the v1 status.jsonl vocabulary (started/connected/characterList/enteredWorld/pluginLoaded/ pluginFailed/disconnected/exited); an unrecognized "e" or a malformed line degrades to a typed Unknown event rather than throwing. StatusFileTailer incrementally reads new lines, tolerating a not-yet-existing file and a partial trailing line (only advances its read position past confirmed '\n' boundaries; a truncated tail is simply re-read next poll, never parsed early). - Integrity/: streaming SHA-256 + hex verify for later pak/download checks (LA9/LA10). Tests: 71 passed (profile CRUD + roster-merge matrix + strict-schema rejection; composer golden-shape tests for gui/guiSelect/headless + password-absence; supervisor tests against both an injected fake child (state-machine determinism) and a real spawned `dotnet --version` child (genuine cross-platform stdin/exit-code proof); tailer tests incl. partial-line and not-yet-existing-file; SHA-256 tests). Verified green on Windows (Release) and native WSL/Linux (Release) — the Linux 0600 test executes its real assertion body under WSL rather than early-returning. Co-Authored-By: Claude Fable 5 --- AcDream.slnx | 2 + .../AcDream.Launcher.Core.csproj | 12 + .../Integrity/FileIntegrity.cs | 61 +++ .../Launching/ILauncherChildProcess.cs | 120 ++++++ .../Launching/LauncherInstallRecord.cs | 12 + .../Launching/LauncherProcessSpec.cs | 15 + .../Launching/LauncherProcessSupervisor.cs | 146 +++++++ .../Launching/LauncherSessionState.cs | 21 + .../Launching/SessionConfigComposer.cs | 167 ++++++++ .../Launching/SessionConfigDocument.cs | 134 ++++++ .../Profiles/AccountProfile.cs | 22 + .../Profiles/CharacterIdFormat.cs | 32 ++ .../Profiles/CharacterProfile.cs | 31 ++ .../Profiles/CharacterRosterEntry.cs | 19 + .../Profiles/LaunchMode.cs | 35 ++ .../Profiles/LauncherProfileDocument.cs | 16 + .../Profiles/LauncherProfileException.cs | 17 + .../Profiles/LauncherProfileStore.cs | 395 ++++++++++++++++++ .../Profiles/ServerProfile.cs | 19 + .../Status/StatusEvent.cs | 81 ++++ .../Status/StatusEventParser.cs | 247 +++++++++++ .../Status/StatusFileTailer.cs | 118 ++++++ .../AcDream.Launcher.Core.Tests.csproj | 23 + .../Integrity/FileIntegrityTests.cs | 98 +++++ .../LauncherProcessSupervisorTests.cs | 246 +++++++++++ .../Launching/SessionConfigComposerTests.cs | 269 ++++++++++++ .../Profiles/CharacterIdFormatTests.cs | 43 ++ .../Profiles/LauncherProfileStoreTests.cs | 287 +++++++++++++ .../Profiles/RosterMergeTests.cs | 146 +++++++ .../Status/StatusEventParserTests.cs | 125 ++++++ .../Status/StatusFileTailerTests.cs | 172 ++++++++ 31 files changed, 3131 insertions(+) create mode 100644 src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj create mode 100644 src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs create mode 100644 src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs create mode 100644 src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs create mode 100644 src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/AccountProfile.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LaunchMode.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/ServerProfile.cs create mode 100644 src/AcDream.Launcher.Core/Status/StatusEvent.cs create mode 100644 src/AcDream.Launcher.Core/Status/StatusEventParser.cs create mode 100644 src/AcDream.Launcher.Core/Status/StatusFileTailer.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index fa90475d..20f17f98 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj new file mode 100644 index 00000000..89be4968 --- /dev/null +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + latest + true + + + + + diff --git a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs new file mode 100644 index 00000000..327d406a --- /dev/null +++ b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs @@ -0,0 +1,61 @@ +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Integrity; + +/// +/// Streaming SHA-256 for pak/download verification, consumed by the +/// install engine (LA9) and the updater (LA10). Kept minimal in this +/// slice: hash a file and compare its hex digest. +/// +public static class FileIntegrity +{ + /// + /// Computes the lower-case hex SHA-256 digest of a file, streaming it + /// from disk rather than loading it fully into memory (relevant for + /// the ~30 GB pak file LA9 verifies). + /// + public static string ComputeSha256Hex(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + byte[] hash = SHA256.HashData(stream); + return Convert.ToHexStringLower(hash); + } + + public static async Task ComputeSha256HexAsync( + string filePath, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + useAsync: true); + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken) + .ConfigureAwait(false); + return Convert.ToHexStringLower(hash); + } + + /// Case-insensitive hex comparison — callers may receive an + /// expected digest in either case from a manifest or a hand-typed + /// fixture. + public static bool Matches(string actualHex, string expectedHex) + { + ArgumentNullException.ThrowIfNull(actualHex); + ArgumentNullException.ThrowIfNull(expectedHex); + return string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase); + } + + /// Computes and compares in one call. + public static bool Verify(string filePath, string expectedHex) => + Matches(ComputeSha256Hex(filePath), expectedHex); +} diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs new file mode 100644 index 00000000..7ee3ed95 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -0,0 +1,120 @@ +using System.Diagnostics; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Thin seam over so +/// 's lifecycle and Stop +/// (CloseMainWindow, falling back to Kill after a timeout) state machine +/// can be unit-tested against an in-memory fake without spawning a real +/// OS process or depending on real window-message timing — both +/// "injectable for tests" per Campaign LA spec §3. +/// +public interface ILauncherChildProcess : IDisposable +{ + bool HasExited { get; } + + int ExitCode { get; } + + /// The child's redirected stdin. The supervisor writes the + /// account password here (followed by a newline) and then closes it — + /// never anywhere else. + TextWriter StandardInput { get; } + + /// Fires exactly once, when the child process terminates + /// (mirrors with + /// EnableRaisingEvents on). + event EventHandler? Exited; + + void Start(); + + /// Mirrors — requests + /// a graceful close via WM_CLOSE. Returns false for a console/no- + /// window process (never throws), matching the real API. + bool CloseMainWindow(); + + /// Mirrors with + /// entireProcessTree: true. + void Kill(); + + bool WaitForExit(TimeSpan timeout); +} + +/// Creates instances from a +/// . +public interface ILauncherChildProcessFactory +{ + ILauncherChildProcess Create(LauncherProcessSpec spec); +} + +/// Real-process implementation used in production. +public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory +{ + public ILauncherChildProcess Create(LauncherProcessSpec spec) => + new SystemChildProcess(spec); +} + +internal sealed class SystemChildProcess : ILauncherChildProcess +{ + private readonly Process _process; + private bool _raisingEnabled; + + internal SystemChildProcess(LauncherProcessSpec spec) + { + ArgumentNullException.ThrowIfNull(spec); + + var startInfo = new ProcessStartInfo + { + FileName = spec.ExecutablePath, + RedirectStandardInput = true, + UseShellExecute = false, + }; + + foreach (string argument in spec.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (!string.IsNullOrEmpty(spec.WorkingDirectory)) + { + startInfo.WorkingDirectory = spec.WorkingDirectory; + } + + _process = new Process { StartInfo = startInfo }; + } + + public bool HasExited => _process.HasExited; + + public int ExitCode => _process.ExitCode; + + public TextWriter StandardInput => _process.StandardInput; + + public event EventHandler? Exited; + + public void Start() + { + _process.EnableRaisingEvents = true; + _process.Exited += OnExited; + _raisingEnabled = true; + _process.Start(); + } + + public bool CloseMainWindow() => _process.CloseMainWindow(); + + public void Kill() => _process.Kill(entireProcessTree: true); + + public bool WaitForExit(TimeSpan timeout) => _process.WaitForExit(timeout); + + public void Dispose() + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + } + + private void OnExited(object? sender, EventArgs e) => + Exited?.Invoke(this, EventArgs.Empty); +} diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs new file mode 100644 index 00000000..5d8e0c07 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -0,0 +1,12 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// The DAT/pak locations a completed install (LA9) records and every +/// session-config composition consumes for +/// . SHA-256/version bookkeeping +/// for the install record itself is LA9/LA10 scope; this slice only +/// needs the two paths a session config requires. +/// +public sealed record LauncherInstallRecord( + string DatDirectory, + string PreparedAssetPath); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs new file mode 100644 index 00000000..11b59dee --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs @@ -0,0 +1,15 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// What to spawn: the host executable path + argument list (both +/// injectable per Campaign LA spec §3, e.g. AcDream.Headless --config +/// <path> or AcDream.App --session-config <path>). +/// Deliberately carries no credential field — the password is a separate +/// transient parameter to +/// that flows only to the child's stdin, never into this spec, an +/// argument list, or a process environment. +/// +public sealed record LauncherProcessSpec( + string ExecutablePath, + IReadOnlyList Arguments, + string? WorkingDirectory = null); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs new file mode 100644 index 00000000..c6749fdf --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -0,0 +1,146 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// Spawns a host process (App/Headless), feeds the account password to +/// its stdin then closes it, and supervises its lifetime (Campaign LA +/// spec §3/§6). One supervisor instance owns exactly one child process +/// for its lifetime — start a new supervisor per launched session. +/// +public sealed class LauncherProcessSupervisor : IDisposable +{ + private readonly ILauncherChildProcessFactory _factory; + private readonly object _gate = new(); + private ILauncherChildProcess? _process; + + public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) + { + _factory = factory ?? new SystemChildProcessFactory(); + } + + public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting; + + /// Set once reaches + /// ; null before then. + /// + public int? ExitCode { get; private set; } + + /// Fires on every + /// transition, in order. + public event EventHandler? StateChanged; + + /// + /// Spawns the child described by , writes + /// (if any) followed by a newline to its + /// stdin, then closes stdin. The password is never written anywhere + /// else — not into , not into an environment + /// variable, not logged. + /// + public void Start(LauncherProcessSpec spec, string? password) + { + ArgumentNullException.ThrowIfNull(spec); + + ILauncherChildProcess process; + lock (_gate) + { + if (_process is not null) + { + throw new InvalidOperationException( + "This supervisor already owns a process; start a new " + + "supervisor per launched session."); + } + + process = _factory.Create(spec); + process.Exited += OnProcessExited; + _process = process; + } + + SetState(LauncherSessionState.Starting); + + try + { + process.Start(); + + if (password is not null) + { + process.StandardInput.Write(password); + process.StandardInput.Write('\n'); + process.StandardInput.Flush(); + } + + process.StandardInput.Close(); + } + catch + { + lock (_gate) + { + process.Exited -= OnProcessExited; + _process = null; + } + + throw; + } + + SetState(LauncherSessionState.Running); + } + + /// + /// Requests a graceful stop (CloseMainWindow), falling back to Kill + /// if the process has not exited within . + /// A no-op if was never called or the process has + /// already exited. + /// + public void Stop(TimeSpan timeout) + { + ILauncherChildProcess? process; + lock (_gate) + { + process = _process; + } + + if (process is null || process.HasExited) + { + return; + } + + process.CloseMainWindow(); + if (!process.WaitForExit(timeout) && !process.HasExited) + { + process.Kill(); + } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + ILauncherChildProcess? process; + lock (_gate) + { + process = _process; + } + + ExitCode = process is { HasExited: true } ? process.ExitCode : null; + SetState(LauncherSessionState.Exited); + } + + private void SetState(LauncherSessionState state) + { + lock (_gate) + { + State = state; + } + + StateChanged?.Invoke(this, state); + } + + public void Dispose() + { + lock (_gate) + { + if (_process is not null) + { + _process.Exited -= OnProcessExited; + _process.Dispose(); + _process = null; + } + } + } +} diff --git a/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs new file mode 100644 index 00000000..2f04fae0 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs @@ -0,0 +1,21 @@ +namespace AcDream.Launcher.Core.Launching; + +/// Lifecycle of a launched host process, per Campaign LA spec §3 +/// ("supervise lifetime ... surface typed session state"). +public enum LauncherSessionState +{ + /// The child process has been created and the credential + /// handed off, but has not yet reached . + Starting, + + /// The child process is spawned and its stdin has been + /// closed. Says nothing about game-level connection state — that + /// comes from the status stream (see + /// AcDream.Launcher.Core.Status). + Running, + + /// The child process has exited. See + /// for the exit + /// code. + Exited, +} diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs new file mode 100644 index 00000000..9bba0226 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Profiles; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Launching; + +/// The composed session-config document plus the two per-launch +/// paths derived from the session id, per Campaign LA spec §6. +public sealed record ComposedSessionConfig( + string SessionId, + string ConfigFilePath, + string StatusFilePath, + SessionConfigDocument Document); + +/// +/// Builds the per-launch from a +/// profile character + install record (Campaign LA spec §6). Passwords +/// NEVER appear in the composed document — the credential is always the +/// standardInput provider; the launcher feeds the password to the +/// child process's stdin separately (). +/// +public static class SessionConfigComposer +{ + internal static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + /// + /// Builds the document and the paths it would be written to under + /// <CacheDirectory>/launcher/sessions/<sessionId>/, + /// without touching disk. is caller- + /// supplied so composition stays a pure function of its inputs + /// (golden-file tests pass a fixed id). + /// + public static ComposedSessionConfig Compose( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(account); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(install); + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + string sessionDirectory = Path.Combine( + paths.CacheDirectory, + "launcher", + "sessions", + sessionId); + string configFilePath = Path.Combine(sessionDirectory, "session.json"); + string statusFilePath = Path.Combine(sessionDirectory, "status.jsonl"); + + SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect + ? null + : BuildSelector(character); + + SessionPolicyDescriptor? policy = character.LaunchMode == LaunchMode.Headless + ? new SessionPolicyDescriptor() + : null; + + var descriptor = new SessionDescriptor + { + Id = sessionId, + Endpoint = new SessionEndpointDescriptor + { + Host = server.Host, + Port = server.Port, + }, + Account = account.Account, + Character = selector, + Policy = policy, + Credential = new SessionCredentialDescriptor(), + Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null, + LoginCommands = character.LoginCommands.Count > 0 + ? [.. character.LoginCommands] + : null, + LoginCommandDelayMs = loginCommandDelayMs, + StatusFile = statusFilePath, + }; + + var document = new SessionConfigDocument + { + Process = new SessionProcessSettings + { + Paths = new SessionPathOverrides(), + Content = new SessionContentDescriptor + { + DatDirectory = install.DatDirectory, + PreparedAssetPath = install.PreparedAssetPath, + }, + }, + Sessions = [descriptor], + }; + + return new ComposedSessionConfig( + sessionId, + configFilePath, + statusFilePath, + document); + } + + /// Composes and writes session.json to + /// , creating the + /// per-session directory. The status file itself is created by the + /// launched host, not the launcher. + public static ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) + { + ComposedSessionConfig composed = Compose( + server, + account, + character, + install, + paths, + sessionId, + loginCommandDelayMs); + + string? directory = Path.GetDirectoryName(composed.ConfigFilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using FileStream stream = File.Create(composed.ConfigFilePath); + JsonSerializer.Serialize(stream, composed.Document, SerializerOptions); + + return composed; + } + + /// Serializes the composed document exactly as + /// would write it — used by golden-file + /// tests that assert on the JSON text without touching disk. + public static string Serialize(SessionConfigDocument document) => + JsonSerializer.Serialize(document, SerializerOptions); + + private static SessionCharacterSelector BuildSelector(CharacterProfile character) + { + if (CharacterIdFormat.TryParse(character.Id, out uint id)) + { + return new SessionCharacterSelector { Id = id }; + } + + if (!string.IsNullOrWhiteSpace(character.Name)) + { + return new SessionCharacterSelector { Name = character.Name }; + } + + throw new InvalidOperationException( + "Character has neither a usable id nor a name to select by."); + } +} diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs new file mode 100644 index 00000000..fca7c970 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -0,0 +1,134 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// The per-launch session-config document written to +/// <CacheDirectory>/launcher/sessions/<sessionId>/session.json +/// and consumed by AcDream.Headless --config / (LA1) +/// AcDream.App --session-config. +/// +/// +/// PINNED CONTRACT (Campaign LA plan §LA3): this is the Slice K1 +/// HeadlessConfiguration version-1 shape extended with optional +/// launcher fields (plugins, loginCommands, +/// loginCommandDelayMs, statusFile). Launcher.Core defines +/// its own DTOs rather than referencing AcDream.Headless — the +/// project reference set for this assembly is AcDream.Platform +/// ONLY (no game-solution dependency; see LA3 acceptance). +/// Serialized camelCase via , with +/// null optional members omitted from the written JSON. +/// +/// +public sealed class SessionConfigDocument +{ + public int Version { get; init; } = 1; + + public SessionProcessSettings Process { get; init; } = new(); + + public List Sessions { get; init; } = []; +} + +public sealed class SessionProcessSettings +{ + public SessionPathOverrides Paths { get; init; } = new(); + + public SessionContentDescriptor Content { get; init; } = new(); +} + +/// All three members are optional overrides; a host resolves +/// its own default ApplicationPathSet when a member is +/// omitted. +public sealed class SessionPathOverrides +{ + public string? ConfigDirectory { get; init; } + + public string? DataDirectory { get; init; } + + public string? CacheDirectory { get; init; } +} + +// NOTE: these DTOs are write-only (Launcher.Core composes and serializes +// them; it never deserializes a session-config document back). Members +// that the pinned contract calls "always present" therefore use plain +// non-nullable defaults rather than C#'s `required` modifier — a +// `required` member cannot be given a `= new()` default on a containing +// type without a [SetsRequiredMembers] constructor, and correctness here +// is enforced by SessionConfigComposer's tests, not the compiler. + +public sealed class SessionContentDescriptor +{ + public string DatDirectory { get; init; } = string.Empty; + + public string PreparedAssetPath { get; init; } = string.Empty; +} + +public sealed class SessionDescriptor +{ + public string Id { get; init; } = string.Empty; + + public SessionEndpointDescriptor Endpoint { get; init; } = new(); + + public string Account { get; init; } = string.Empty; + + /// Exactly one of index/id/name when present. OMITTED + /// entirely for a guiSelect launch (retail character-select + /// screen instead of auto-enter). + public SessionCharacterSelector? Character { get; init; } + + /// Present only for a headless launch (the idle + /// bot policy). Omitted for gui/guiSelect. + public SessionPolicyDescriptor? Policy { get; init; } + + public SessionCredentialDescriptor Credential { get; init; } = new(); + + /// Omitted (never an empty array) when the character has no + /// configured plugin set. + public List? Plugins { get; init; } + + /// Omitted (never an empty array) when the character has no + /// configured login commands. + public List? LoginCommands { get; init; } + + /// Overrides the host's default 500 ms inter-command + /// delay when set; omitted otherwise. + public int? LoginCommandDelayMs { get; init; } + + public string StatusFile { get; init; } = string.Empty; +} + +public sealed class SessionEndpointDescriptor +{ + public string Host { get; init; } = string.Empty; + + public int Port { get; init; } +} + +/// Exactly one of // +/// is set by . +/// +public sealed class SessionCharacterSelector +{ + public int? Index { get; init; } + + public uint? Id { get; init; } + + public string? Name { get; init; } +} + +/// Campaign LA composes exactly the idle bot policy (LA2) +/// for headless launches — the launcher never asks for any other +/// policy id. +public sealed class SessionPolicyDescriptor +{ + public string Id { get; init; } = "idle"; +} + +/// Always the standardInput provider — the launcher pipes +/// the account password to the child's stdin and never places it in the +/// session-config document, process arguments, or environment (see +/// ). +public sealed class SessionCredentialDescriptor +{ + public string Provider { get; init; } = "standardInput"; + + public string Reference { get; init; } = "session"; +} diff --git a/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs new file mode 100644 index 00000000..03b3693c --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One account under a server, per Campaign LA spec §5. The password is +/// plaintext by explicit user decision +/// (claude-memory/project_launcher_direction.md) — never written +/// anywhere except this file, never logged, never placed in a session +/// config or process argument/environment (see +/// ). +/// +public sealed class AccountProfile +{ + [JsonRequired] + public string Account { get; set; } = string.Empty; + + [JsonRequired] + public string Password { get; set; } = string.Empty; + + public List Characters { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs new file mode 100644 index 00000000..55570cd5 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs @@ -0,0 +1,32 @@ +using System.Globalization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Converts between the wire uint character GUID and the +/// launcher-profile hex-string representation ("0x5000000A", +/// matching the convention used throughout the project, e.g. the +/// +Acdream test character's 0x5000000A in CLAUDE.md). +/// +public static class CharacterIdFormat +{ + public static string ToHexString(uint id) => + "0x" + id.ToString("X8", CultureInfo.InvariantCulture); + + public static bool TryParse(string? text, out uint id) + { + id = 0; + if (string.IsNullOrWhiteSpace(text)) + return false; + + ReadOnlySpan span = text.AsSpan().Trim(); + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + span = span[2..]; + + return uint.TryParse( + span, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out id); + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs new file mode 100644 index 00000000..18827873 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One character row under an account, per Campaign LA spec §5. The +/// / pair is the launcher-maintained +/// cache (fed by status-stream characterList events and roster +/// probes via ); +/// // +/// are user-owned settings that a roster merge must never clobber. +/// +public sealed class CharacterProfile +{ + [JsonRequired] + public string Name { get; set; } = string.Empty; + + /// + /// Hex-formatted character GUID (e.g. "0x5000000A"), matching + /// the convention used elsewhere in the project. Null only for a + /// hand-authored fixture/profile entry that has never been through a + /// roster merge. + /// + public string? Id { get; set; } + + public LaunchMode LaunchMode { get; set; } = LaunchMode.GuiSelect; + + public List Plugins { get; set; } = []; + + public List LoginCommands { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs new file mode 100644 index 00000000..91b3ea6b --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs @@ -0,0 +1,19 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One roster row as reported by a host's characterList status +/// event or an on-demand probe launch (Campaign LA spec §3/§6). Mirrors +/// the wire shape of AcDream.Core.Net.Messages.CharacterList.Character +/// (uint Id, string Name, uint SecondsGreyedOut) — Launcher.Core +/// does not reference Core.Net, so this is an independent, intentionally +/// identical shape fed by the status-stream parser +/// (). +/// is carried for completeness but is +/// NEVER persisted into — ACE reports a +/// constant 1 during the pending-delete grace window (a boolean, not a +/// countdown), and the profile schema (§5) has no field for it. +/// +public readonly record struct CharacterRosterEntry( + uint Id, + string Name, + uint SecondsGreyedOut); diff --git a/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs new file mode 100644 index 00000000..c3c02633 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs @@ -0,0 +1,35 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Per-character launch behaviour (Campaign LA spec §5). Stored on each +/// and read by +/// to +/// decide the shape of the composed session-config document. +/// +/// +/// Serialized as camelCase text ("gui"/"guiSelect"/ +/// "headless") via the explicit +/// new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, ...) +/// registered in 's serializer options +/// — deliberately NOT a per-type [JsonConverter] attribute, which +/// uses exact member-name casing ("Gui") regardless of the +/// ambient PropertyNamingPolicy. +/// +/// +public enum LaunchMode +{ + /// Launch the graphical client straight into the world as + /// this character. + Gui, + + /// Launch the graphical client but stop at the retail + /// character-select screen — no character selector is sent. This is + /// the default for a character that has never had its launch mode set + /// explicitly. + GuiSelect, + + /// Launch the no-window host running the idle bot + /// policy (enter world, run plugins/login commands, stay until + /// stopped). + Headless, +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs new file mode 100644 index 00000000..771d3dc2 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Root document for launcher-profiles.json (Campaign LA spec §5) +/// — the launcher's ONLY credential/profile store. Loaded and saved by +/// . +/// +public sealed class LauncherProfileDocument +{ + [JsonRequired] + public int Version { get; set; } = LauncherProfileStore.CurrentVersion; + + public List Servers { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs new file mode 100644 index 00000000..7bef5a6a --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs @@ -0,0 +1,17 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// Thrown for a malformed launcher-profiles.json document +/// or an invalid CRUD operation against +/// (unknown target, duplicate name, etc.). +public sealed class LauncherProfileException : Exception +{ + public LauncherProfileException(string message) + : base(message) + { + } + + public LauncherProfileException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs new file mode 100644 index 00000000..2fac9f0b --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -0,0 +1,395 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Load/save/CRUD owner for launcher-profiles.json (Campaign LA +/// spec §5) — the launcher's ONLY credential/profile store, and the +/// binding surface the Avalonia UI (slice LA4) mutates directly. +/// +/// +/// A store instance holds the current in-memory +/// after ; every CRUD method mutates that document in +/// place so callers can chain store.AddServer(...); store.Save(); +/// without re-threading a returned document through every call. +/// +/// +public sealed class LauncherProfileStore +{ + internal const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + AllowTrailingCommas = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + ReadCommentHandling = JsonCommentHandling.Disallow, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, + }; + + public LauncherProfileStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + FilePath = Path.GetFullPath(filePath); + Document = new LauncherProfileDocument(); + } + + /// Resolve the store at the canonical location under + /// + /// (%APPDATA%\acdream\launcher-profiles.json / + /// ~/.config/acdream/launcher-profiles.json). + public static LauncherProfileStore ForApplicationPaths(ApplicationPathSet paths) + { + ArgumentNullException.ThrowIfNull(paths); + return new LauncherProfileStore( + Path.Combine(paths.ConfigDirectory, "launcher-profiles.json")); + } + + public string FilePath { get; } + + public LauncherProfileDocument Document { get; private set; } + + /// + /// Loads from . A + /// missing file is not an error — it resolves to a fresh empty + /// document (version 1, no servers), matching a never-launched + /// installation. Returns true when a file was actually read. + /// + public bool Load() + { + if (!File.Exists(FilePath)) + { + Document = new LauncherProfileDocument(); + return false; + } + + LauncherProfileDocument? document; + using (FileStream stream = File.OpenRead(FilePath)) + { + try + { + document = JsonSerializer.Deserialize( + stream, + SerializerOptions); + } + catch (JsonException ex) + { + throw new LauncherProfileException( + $"'{FilePath}' is not a valid launcher profile document.", + ex); + } + } + + if (document is null) + { + throw new LauncherProfileException($"'{FilePath}' is empty."); + } + + if (document.Version != CurrentVersion) + { + throw new LauncherProfileException( + $"Unsupported launcher-profiles version {document.Version}; " + + $"expected {CurrentVersion}."); + } + + Document = document; + return true; + } + + /// + /// Persists to via a + /// write-then-atomic-rename so a crash mid-write never leaves a + /// truncated credentials file. On Linux, restricts the final file to + /// owner read/write (0600) per Campaign LA's plaintext-credential + /// decision (spec §5, decisions log). + /// + public void Save() + { + string? directory = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string tempPath = FilePath + ".tmp"; + using (FileStream stream = File.Create(tempPath)) + { + JsonSerializer.Serialize(stream, Document, SerializerOptions); + } + + File.Move(tempPath, FilePath, overwrite: true); + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode( + FilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + // --- Server CRUD ----------------------------------------------- + + public ServerProfile AddServer(string name, string host, int port) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(host); + RequireValidPort(port); + + if (FindServer(name) is not null) + { + throw new LauncherProfileException( + $"A server named '{name}' already exists."); + } + + var server = new ServerProfile { Name = name, Host = host, Port = port }; + Document.Servers.Add(server); + return server; + } + + public void EditServer( + string name, + string? newName = null, + string? newHost = null, + int? newPort = null) + { + ServerProfile server = FindServerOrThrow(name); + + if (newName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + if (!string.Equals(newName, server.Name, StringComparison.Ordinal) + && FindServer(newName) is not null) + { + throw new LauncherProfileException( + $"A server named '{newName}' already exists."); + } + + server.Name = newName; + } + + if (newHost is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newHost); + server.Host = newHost; + } + + if (newPort is not null) + { + RequireValidPort(newPort.Value); + server.Port = newPort.Value; + } + } + + public void RemoveServer(string name) + { + ServerProfile server = FindServerOrThrow(name); + Document.Servers.Remove(server); + } + + // --- Account CRUD ------------------------------------------------ + + public AccountProfile AddAccount(string serverName, string account, string password) + { + ArgumentException.ThrowIfNullOrWhiteSpace(account); + ArgumentNullException.ThrowIfNull(password); + ServerProfile server = FindServerOrThrow(serverName); + + if (FindAccount(server, account) is not null) + { + throw new LauncherProfileException( + $"Account '{account}' already exists on server '{serverName}'."); + } + + var profile = new AccountProfile { Account = account, Password = password }; + server.Accounts.Add(profile); + return profile; + } + + public void EditAccount( + string serverName, + string account, + string? newAccount = null, + string? newPassword = null) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + if (newAccount is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newAccount); + if (!string.Equals(newAccount, profile.Account, StringComparison.Ordinal) + && FindAccount(server, newAccount) is not null) + { + throw new LauncherProfileException( + $"Account '{newAccount}' already exists on server '{serverName}'."); + } + + profile.Account = newAccount; + } + + if (newPassword is not null) + { + profile.Password = newPassword; + } + } + + public void RemoveAccount(string serverName, string account) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + server.Accounts.Remove(profile); + } + + // --- Character settings (roster-driven add/remove; user-edited settings) --- + + /// + /// Edits the user-owned settings of an existing character row. There + /// is no manual add/remove for characters — the roster ( + /// ) is the only source of new rows, per + /// spec §5/§6. + /// + public void EditCharacter( + string serverName, + string account, + string characterName, + LaunchMode? launchMode = null, + IReadOnlyList? plugins = null, + IReadOnlyList? loginCommands = null) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + CharacterProfile character = FindCharacterOrThrow(profile, characterName); + + if (launchMode is not null) + { + character.LaunchMode = launchMode.Value; + } + + if (plugins is not null) + { + character.Plugins = [.. plugins]; + } + + if (loginCommands is not null) + { + character.LoginCommands = [.. loginCommands]; + } + } + + /// + /// Folds a reported character roster into an account's + /// (Campaign LA spec §3/§5/ + /// §6): every roster entry either updates the name of an existing + /// row (matched by ) while + /// PRESERVING that row's user settings (, + /// , + /// ), or is inserted as a + /// new row with default settings (, + /// no plugins, no login commands). Existing rows absent from the + /// roster are RETAINED unchanged — they may simply be pending-delete + /// (ACE keeps deleted characters queryable during the grace window) + /// or the roster snapshot may be partial; this store never deletes a + /// character row on the caller's behalf. + /// + public void MergeRoster( + string serverName, + string account, + IReadOnlyList roster) + { + ArgumentNullException.ThrowIfNull(roster); + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + foreach (CharacterRosterEntry entry in roster) + { + string idText = CharacterIdFormat.ToHexString(entry.Id); + CharacterProfile? existing = profile.Characters.Find( + character => string.Equals( + character.Id, + idText, + StringComparison.OrdinalIgnoreCase)); + + // Defensive fallback for a hand-edited file where a character + // row was added with a name but no id yet. + existing ??= profile.Characters.Find( + character => character.Id is null + && string.Equals( + character.Name, + entry.Name, + StringComparison.Ordinal)); + + if (existing is not null) + { + existing.Id = idText; + existing.Name = entry.Name; + continue; + } + + profile.Characters.Add(new CharacterProfile + { + Id = idText, + Name = entry.Name, + LaunchMode = LaunchMode.GuiSelect, + Plugins = [], + LoginCommands = [], + }); + } + } + + // --- Lookups ------------------------------------------------------- + + private ServerProfile? FindServer(string name) => + Document.Servers.Find( + server => string.Equals(server.Name, name, StringComparison.Ordinal)); + + private ServerProfile FindServerOrThrow(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return FindServer(name) + ?? throw new LauncherProfileException($"No server named '{name}'."); + } + + private static AccountProfile? FindAccount(ServerProfile server, string account) => + server.Accounts.Find( + candidate => string.Equals(candidate.Account, account, StringComparison.Ordinal)); + + private static AccountProfile FindAccountOrThrow(ServerProfile server, string account) + { + ArgumentException.ThrowIfNullOrWhiteSpace(account); + return FindAccount(server, account) + ?? throw new LauncherProfileException( + $"No account '{account}' on server '{server.Name}'."); + } + + private static CharacterProfile FindCharacterOrThrow( + AccountProfile profile, + string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + return profile.Characters.Find( + character => string.Equals( + character.Name, + characterName, + StringComparison.Ordinal)) + ?? throw new LauncherProfileException( + $"No character '{characterName}' on account '{profile.Account}'."); + } + + private static void RequireValidPort(int port) + { + if (port is < 1 or > 65535) + { + throw new LauncherProfileException( + $"Port {port} is outside the valid 1-65535 range."); + } + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs new file mode 100644 index 00000000..3a5f0388 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// One server entry, per Campaign LA spec §5 (manual add — no +/// published server-list import this campaign). +public sealed class ServerProfile +{ + [JsonRequired] + public string Name { get; set; } = string.Empty; + + [JsonRequired] + public string Host { get; set; } = string.Empty; + + [JsonRequired] + public int Port { get; set; } + + public List Accounts { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs new file mode 100644 index 00000000..b216392b --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -0,0 +1,81 @@ +namespace AcDream.Launcher.Core.Status; + +/// +/// One parsed line of a host's status.jsonl stream (Campaign LA +/// spec §6). Every event carries the versioned envelope +/// (v/e/t/sessionId) plus its own typed +/// payload. See for the wire shape and +/// for the +/// incremental reader that produces these. +/// +public abstract record StatusEvent +{ + public required int V { get; init; } + + public required string E { get; init; } + + public required DateTimeOffset T { get; init; } + + public required string SessionId { get; init; } +} + +public sealed record StartedStatusEvent : StatusEvent; + +public sealed record ConnectedStatusEvent : StatusEvent; + +public readonly record struct StatusCharacterEntry( + uint Id, + string Name, + int SecondsGreyedOut); + +public sealed record CharacterListStatusEvent : StatusEvent +{ + public required string AccountName { get; init; } + + public required int SlotCount { get; init; } + + public required IReadOnlyList Characters { get; init; } +} + +public sealed record EnteredWorldStatusEvent : StatusEvent +{ + public required uint CharacterId { get; init; } + + public required string CharacterName { get; init; } +} + +public sealed record PluginLoadedStatusEvent : StatusEvent +{ + public required string Plugin { get; init; } +} + +public sealed record PluginFailedStatusEvent : StatusEvent +{ + public required string Plugin { get; init; } + + public required string Error { get; init; } +} + +public sealed record DisconnectedStatusEvent : StatusEvent +{ + public required string Reason { get; init; } +} + +public sealed record ExitedStatusEvent : StatusEvent +{ + public required int Code { get; init; } + + public required string Reason { get; init; } +} + +/// +/// A well-formed status line whose e value (or overall envelope +/// shape) this reader does not recognize. The tailer never throws on an +/// unrecognized event — an older launcher reading a newer host's stream +/// degrades to seeing rows instead of +/// crashing. +/// +public sealed record UnknownStatusEvent : StatusEvent +{ + public required string RawJson { get; init; } +} diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs new file mode 100644 index 00000000..17495b7c --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -0,0 +1,247 @@ +using System.Text.Json; + +namespace AcDream.Launcher.Core.Status; + +/// +/// Parses one status.jsonl line (Campaign LA spec §6) into a typed +/// . Wire shape: every line is a flat JSON +/// object carrying the envelope (v, e, t, +/// sessionId) alongside that event's own fields — e.g. +/// {"v":1,"e":"characterList","t":"...","sessionId":"...", +/// "accountName":"...","slotCount":6,"characters":[...]}. +/// +/// +/// Never throws: a line whose e is not one of the eight known +/// values, or whose payload doesn't match that event's expected shape, +/// or that isn't valid JSON at all, degrades to a typed +/// rather than an exception — a +/// launcher must keep tailing a session's status stream even against a +/// host running a newer/older wire version. +/// +/// +public static class StatusEventParser +{ + public static StatusEvent Parse(string line) + { + ArgumentException.ThrowIfNullOrWhiteSpace(line); + + try + { + using JsonDocument document = JsonDocument.Parse(line); + JsonElement root = document.RootElement; + + int v = GetInt32OrDefault(root, "v"); + string e = GetStringOrDefault(root, "e"); + DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t"); + string sessionId = GetStringOrDefault(root, "sessionId"); + + return e switch + { + "started" => + new StartedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "connected" => + new ConnectedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "characterList" => + ParseCharacterList(root, v, e, t, sessionId), + "enteredWorld" => + ParseEnteredWorld(root, v, e, t, sessionId), + "pluginLoaded" => + ParsePluginLoaded(root, v, e, t, sessionId), + "pluginFailed" => + ParsePluginFailed(root, v, e, t, sessionId), + "disconnected" => + ParseDisconnected(root, v, e, t, sessionId), + "exited" => + ParseExited(root, v, e, t, sessionId), + _ => + new UnknownStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + RawJson = line, + }, + }; + } + catch (Exception) + { + // JsonException (malformed JSON), FormatException (a + // required-field miss inside a Parse* helper) — all degrade + // the same way: never throw out of the tailer. + return new UnknownStatusEvent + { + V = 0, + E = string.Empty, + T = default, + SessionId = string.Empty, + RawJson = line, + }; + } + } + + private static StatusEvent ParseCharacterList( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) + { + string accountName = RequireString(root, "accountName"); + int slotCount = RequireInt32(root, "slotCount"); + JsonElement charactersElement = RequireProperty(root, "characters"); + + var characters = new List(); + foreach (JsonElement item in charactersElement.EnumerateArray()) + { + uint id = RequireUInt32(item, "id"); + string name = RequireString(item, "name"); + int secondsGreyedOut = RequireInt32(item, "secondsGreyedOut"); + characters.Add(new StatusCharacterEntry(id, name, secondsGreyedOut)); + } + + return new CharacterListStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + AccountName = accountName, + SlotCount = slotCount, + Characters = characters, + }; + } + + private static StatusEvent ParseEnteredWorld( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new EnteredWorldStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + CharacterId = RequireUInt32(root, "characterId"), + CharacterName = RequireString(root, "characterName"), + }; + + private static StatusEvent ParsePluginLoaded( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new PluginLoadedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Plugin = RequireString(root, "plugin"), + }; + + private static StatusEvent ParsePluginFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new PluginFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Plugin = RequireString(root, "plugin"), + Error = RequireString(root, "error"), + }; + + private static StatusEvent ParseDisconnected( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new DisconnectedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Reason = RequireString(root, "reason"), + }; + + private static StatusEvent ParseExited( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new ExitedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Code = RequireInt32(root, "code"), + Reason = RequireString(root, "reason"), + }; + + private static int GetInt32OrDefault(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.Number + && element.TryGetInt32(out int value) + ? value + : 0; + + private static string GetStringOrDefault(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + ? element.GetString() ?? string.Empty + : string.Empty; + + private static DateTimeOffset GetDateTimeOffsetOrDefault( + JsonElement root, + string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse( + element.GetString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out DateTimeOffset value) + ? value + : default; + + private static JsonElement RequireProperty(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + ? element + : throw new FormatException($"status event is missing '{name}'."); + + private static string RequireString(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.String + ? element.GetString() ?? string.Empty + : throw new FormatException($"status event field '{name}' is not a string."); + } + + private static int RequireInt32(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value) + ? value + : throw new FormatException($"status event field '{name}' is not an integer."); + } + + private static uint RequireUInt32(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value) + ? value + : throw new FormatException($"status event field '{name}' is not an unsigned integer."); + } +} diff --git a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs new file mode 100644 index 00000000..69b81852 --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs @@ -0,0 +1,118 @@ +using System.Text; + +namespace AcDream.Launcher.Core.Status; + +/// +/// Incremental reader over a host's status.jsonl file (Campaign LA +/// spec §3/§6). Each call to returns the +/// events that arrived since the previous call, tolerating: +/// +/// the file not existing yet (the launcher may start tailing +/// before the host has written its first line — returns no events, not +/// an error); +/// a partial last line (the host may be mid-write when polled — +/// the tailer only advances its read position past the last confirmed +/// '\n'; a still-incomplete tail is re-read, combined with +/// whatever gets appended, on the next poll — never parsed while +/// truncated). +/// +/// One tailer instance owns one file's read position; construct a new +/// one per session. +/// +public sealed class StatusFileTailer +{ + private readonly string _path; + private long _position; + + public StatusFileTailer(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _path = path; + } + + /// + /// Reads and parses every complete line appended to the file since + /// the last call. Returns an empty list (never null, never throws) + /// when the file doesn't exist yet or nothing new/complete has + /// arrived since the last poll. + /// + public IReadOnlyList ReadNewEvents() + { + if (!File.Exists(_path)) + { + return []; + } + + using var stream = new FileStream( + _path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + + if (stream.Length < _position) + { + // The file was truncated/replaced under us (e.g. a fresh + // session reusing a stale path) — restart from the top + // rather than throwing or silently missing the new content. + _position = 0; + } + + if (stream.Length == _position) + { + return []; + } + + stream.Seek(_position, SeekOrigin.Begin); + int unreadByteCount = checked((int)(stream.Length - _position)); + byte[] buffer = new byte[unreadByteCount]; + int totalRead = 0; + while (totalRead < unreadByteCount) + { + int read = stream.Read(buffer, totalRead, unreadByteCount - totalRead); + if (read == 0) + { + break; + } + + totalRead += read; + } + + var events = new List(); + int lineStart = 0; + + // How far into `buffer` we've confirmed a complete line — this + // is where `_position` advances to. Bytes after this point (an + // in-progress line with no trailing '\n' yet) are simply left + // unread on disk; the next poll re-reads them from `_position` + // combined with whatever the writer appends in between. No + // separate in-memory carry-over buffer is needed. + int consumedThroughIndex = 0; + + for (int i = 0; i < totalRead; i++) + { + if (buffer[i] != (byte)'\n') + { + continue; + } + + int lineEnd = i; + if (lineEnd > lineStart && buffer[lineEnd - 1] == (byte)'\r') + { + lineEnd--; + } + + if (lineEnd > lineStart) + { + string rawLine = Encoding.UTF8.GetString(buffer, lineStart, lineEnd - lineStart); + events.Add(StatusEventParser.Parse(rawLine)); + } + + lineStart = i + 1; + consumedThroughIndex = lineStart; + } + + _position += consumedThroughIndex; + + return events; + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj new file mode 100644 index 00000000..6f77e682 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + latest + true + + + + + + + + + + + + + + + diff --git a/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs b/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs new file mode 100644 index 00000000..106106c7 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; +using System.Text; +using AcDream.Launcher.Core.Integrity; + +namespace AcDream.Launcher.Core.Tests.Integrity; + +public sealed class FileIntegrityTests : IDisposable +{ + private readonly string _root; + + public FileIntegrityTests() + { + _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-integrity-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void ComputeSha256HexMatchesTheFrameworkHasher() + { + string path = Path.Combine(_root, "file.bin"); + byte[] content = Encoding.UTF8.GetBytes("acdream launcher integrity fixture"); + File.WriteAllBytes(path, content); + string expected = Convert.ToHexStringLower(SHA256.HashData(content)); + + string actual = FileIntegrity.ComputeSha256Hex(path); + + Assert.Equal(expected, actual); + } + + [Fact] + public async Task ComputeSha256HexAsyncMatchesTheSyncResult() + { + string path = Path.Combine(_root, "file.bin"); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes("async path fixture")); + + string sync = FileIntegrity.ComputeSha256Hex(path); + string asyncResult = await FileIntegrity.ComputeSha256HexAsync(path); + + Assert.Equal(sync, asyncResult); + } + + [Fact] + public void VerifySucceedsForAMatchingDigestRegardlessOfCase() + { + string path = Path.Combine(_root, "file.bin"); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes("case-insensitive fixture")); + string lower = FileIntegrity.ComputeSha256Hex(path); + + Assert.True(FileIntegrity.Verify(path, lower)); + Assert.True(FileIntegrity.Verify(path, lower.ToUpperInvariant())); + } + + [Fact] + public void VerifyFailsForAMismatchedDigest() + { + string path = Path.Combine(_root, "file.bin"); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes("original content")); + + Assert.False(FileIntegrity.Verify(path, new string('0', 64))); + } + + [Fact] + public void DifferentContentProducesDifferentDigests() + { + string pathA = Path.Combine(_root, "a.bin"); + string pathB = Path.Combine(_root, "b.bin"); + File.WriteAllBytes(pathA, Encoding.UTF8.GetBytes("content A")); + File.WriteAllBytes(pathB, Encoding.UTF8.GetBytes("content B")); + + Assert.NotEqual( + FileIntegrity.ComputeSha256Hex(pathA), + FileIntegrity.ComputeSha256Hex(pathB)); + } + + [Fact] + public void EmptyFileHashesToTheWellKnownSha256OfEmptyInput() + { + string path = Path.Combine(_root, "empty.bin"); + File.WriteAllBytes(path, []); + + string actual = FileIntegrity.ComputeSha256Hex(path); + + Assert.Equal( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + actual); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs new file mode 100644 index 00000000..df12ea29 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -0,0 +1,246 @@ +using System.Threading; +using AcDream.Launcher.Core.Launching; + +namespace AcDream.Launcher.Core.Tests.Launching; + +public sealed class LauncherProcessSupervisorTests +{ + [Fact] + public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + var states = new List(); + supervisor.StateChanged += (_, s) => states.Add(s); + + supervisor.Start(Spec(), "S3cretPassw0rd!"); + + FakeChildProcess fake = factory.LastCreated!; + Assert.True(fake.Started); + Assert.Equal("S3cretPassw0rd!\n", fake.StandardInputText); + Assert.True(fake.StandardInputClosed); + Assert.Equal(LauncherSessionState.Running, supervisor.State); + Assert.Equal( + [LauncherSessionState.Starting, LauncherSessionState.Running], + states); + } + + [Fact] + public void StartWithNullPasswordClosesStdinWithoutWriting() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + + supervisor.Start(Spec(), password: null); + + FakeChildProcess fake = factory.LastCreated!; + Assert.Equal(string.Empty, fake.StandardInputText); + Assert.True(fake.StandardInputClosed); + } + + [Fact] + public void StartTwiceOnTheSameSupervisorThrows() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + + Assert.Throws(() => supervisor.Start(Spec(), "pw")); + } + + [Fact] + public void StopCallsCloseMainWindowAndSucceedsWithoutKillWhenTheProcessExitsInTime() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + FakeChildProcess fake = factory.LastCreated!; + Assert.True(fake.CloseMainWindowCalled); + Assert.Equal(0, fake.KillCallCount); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + Assert.Equal(0, supervisor.ExitCode); + } + + [Fact] + public void StopFallsBackToKillWhenTheProcessDoesNotExitWithinTheTimeout() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: false); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + FakeChildProcess fake = factory.LastCreated!; + Assert.True(fake.CloseMainWindowCalled); + Assert.Equal(1, fake.KillCallCount); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + } + + [Fact] + public void StopIsANoOpBeforeStart() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + Assert.Null(factory.LastCreated); + Assert.Equal(LauncherSessionState.Starting, supervisor.State); + } + + [Fact] + public void StopIsANoOpAfterTheProcessHasAlreadyExited() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + FakeChildProcess fake = factory.LastCreated!; + Assert.Equal(0, fake.KillCallCount); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + // CloseMainWindow was called exactly once (the first Stop) — + // Stop after exit does not re-invoke the graceful/kill dance. + Assert.Equal(1, fake.CloseMainWindowCallCount); + Assert.Equal(0, fake.KillCallCount); + } + + [Fact] + public void LauncherProcessSpecCarriesNoCredentialLikeMember() + { + // Defense in depth: the password must never be able to reach + // process arguments or environment (Campaign LA plan §LA3). This + // guards against a future field addition accidentally widening + // that surface. + System.Reflection.PropertyInfo[] properties = + typeof(LauncherProcessSpec).GetProperties(); + Assert.DoesNotContain( + properties, + p => p.Name.Contains("password", StringComparison.OrdinalIgnoreCase) + || p.Name.Contains("credential", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void RealProcessSpawnFeedsStdinAndCapturesExitCode() + { + // The "trivial cross-platform fake child" (plan §LA3 acceptance): + // `dotnet --version` is guaranteed present (we're running under + // `dotnet test`) on both Windows and Linux/WSL, ignores stdin + // entirely, and reliably exits 0 — proving the REAL + // SystemChildProcessFactory spawn/stdin-feed/exit-code-capture + // path end to end without any OS-specific script branching. + string dotnet = FindDotnetExecutable(); + using var supervisor = new LauncherProcessSupervisor(); + var exited = new ManualResetEventSlim(false); + supervisor.StateChanged += (_, s) => + { + if (s == LauncherSessionState.Exited) + exited.Set(); + }; + + supervisor.Start( + new LauncherProcessSpec(dotnet, ["--version"]), + "unused-password-ignored-by-dotnet"); + + bool completed = exited.Wait(TimeSpan.FromSeconds(30)); + + Assert.True(completed, "the real dotnet --version child did not exit within 30s"); + Assert.Equal(0, supervisor.ExitCode); + } + + private static LauncherProcessSpec Spec() => + new("fake-host", ["--session-config", "session.json"]); + + private static string FindDotnetExecutable() => + // PATH-based resolution: .NET Core's Process.Start searches PATH + // for a bare filename when UseShellExecute is false, on both + // Windows and Unix, and `dotnet` is guaranteed on PATH here + // because this test is itself running under `dotnet test`. + OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + + private sealed class FakeChildProcessFactory(bool exitsWithinStopTimeout) + : ILauncherChildProcessFactory + { + public FakeChildProcess? LastCreated { get; private set; } + + public ILauncherChildProcess Create(LauncherProcessSpec spec) + { + LastCreated = new FakeChildProcess(spec, exitsWithinStopTimeout); + return LastCreated; + } + } + + private sealed class FakeChildProcess(LauncherProcessSpec spec, bool exitsWithinStopTimeout) + : ILauncherChildProcess + { + private readonly RecordingTextWriter _standardInput = new(); + + public LauncherProcessSpec Spec { get; } = spec; + + public bool Started { get; private set; } + + public string StandardInputText => _standardInput.ToString(); + + public bool StandardInputClosed => _standardInput.IsClosed; + + public bool CloseMainWindowCalled => CloseMainWindowCallCount > 0; + + public int CloseMainWindowCallCount { get; private set; } + + public int KillCallCount { get; private set; } + + public bool HasExited { get; private set; } + + public int ExitCode { get; private set; } + + public TextWriter StandardInput => _standardInput; + + public event EventHandler? Exited; + + public void Start() => Started = true; + + public bool CloseMainWindow() + { + CloseMainWindowCallCount++; + return true; + } + + public void Kill() + { + KillCallCount++; + HasExited = true; + ExitCode = -1; + Exited?.Invoke(this, EventArgs.Empty); + } + + public bool WaitForExit(TimeSpan timeout) + { + if (!exitsWithinStopTimeout) + return false; + + HasExited = true; + ExitCode = 0; + Exited?.Invoke(this, EventArgs.Empty); + return true; + } + + public void Dispose() + { + } + } + + private sealed class RecordingTextWriter : StringWriter + { + public bool IsClosed { get; private set; } + + protected override void Dispose(bool disposing) + { + IsClosed = true; + base.Dispose(disposing); + } + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs new file mode 100644 index 00000000..49d8615b --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs @@ -0,0 +1,269 @@ +using System.Text.Json.Nodes; +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Tests.Launching; + +/// +/// Golden-shape tests for against the +/// Campaign LA plan §LA3 pinned contract: exactly the listed keys, exact +/// camelCase names, character/policy presence rules per launch mode, and +/// (critically) no password anywhere in the document. +/// +public sealed class SessionConfigComposerTests +{ + private static readonly ApplicationPathSet Paths = new( + ConfigDirectory: "/cfg/acdream", + DataDirectory: "/data/acdream", + CacheDirectory: "/cache/acdream", + LegacyConfigDirectory: null); + + private static readonly LauncherInstallRecord Install = new( + DatDirectory: "/dats", + PreparedAssetPath: "/data/acdream/pak/acdream.pak"); + + private static ServerProfile Server() => + new() { Name = "Local ACE", Host = "127.0.0.1", Port = 9000 }; + + private static AccountProfile Account() => + new() { Account = "testaccount", Password = "S3cretPassw0rd!" }; + + private static CharacterProfile Character(LaunchMode mode, string? id = "0x5000000A") => + new() + { + Name = "+Acdream", + Id = id, + LaunchMode = mode, + Plugins = ["ExamplePlugin"], + LoginCommands = ["/tell someone, hi"], + }; + + [Fact] + public void GuiModeIncludesCharacterSelectorAndOmitsPolicy() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui), + Install, + Paths, + sessionId: "session-gui"); + + JsonObject session = SingleSession(composed); + + AssertKeys( + session, + "id", "endpoint", "account", "character", "credential", + "plugins", "loginCommands", "statusFile"); + + Assert.Equal("session-gui", (string?)session["id"]); + Assert.Equal("testaccount", (string?)session["account"]); + Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]); + Assert.Null(session["character"]!["name"]); + Assert.Null(session["character"]!["index"]); + Assert.Equal("standardInput", (string?)session["credential"]!["provider"]); + Assert.Equal("session", (string?)session["credential"]!["reference"]); + Assert.Equal( + new[] { "ExamplePlugin" }, + session["plugins"]!.AsArray().Select(n => (string?)n)); + Assert.Equal( + new[] { "/tell someone, hi" }, + session["loginCommands"]!.AsArray().Select(n => (string?)n)); + Assert.Equal( + Path.Combine(Paths.CacheDirectory, "launcher", "sessions", "session-gui", "status.jsonl"), + (string?)session["statusFile"]); + } + + [Fact] + public void GuiSelectModeOmitsCharacterFieldEntirely() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.GuiSelect), + Install, + Paths, + sessionId: "session-guiselect"); + + JsonObject session = SingleSession(composed); + + AssertKeys( + session, + "id", "endpoint", "account", "credential", + "plugins", "loginCommands", "statusFile"); + Assert.False(session.ContainsKey("character")); + Assert.False(session.ContainsKey("policy")); + } + + [Fact] + public void HeadlessModeIncludesCharacterAndIdlePolicy() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Headless), + Install, + Paths, + sessionId: "session-headless", + loginCommandDelayMs: 750); + + JsonObject session = SingleSession(composed); + + AssertKeys( + session, + "id", "endpoint", "account", "character", "policy", "credential", + "plugins", "loginCommands", "loginCommandDelayMs", "statusFile"); + Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]); + Assert.Equal("idle", (string?)session["policy"]!["id"]); + Assert.Equal(750, (int?)session["loginCommandDelayMs"]); + } + + [Fact] + public void GuiModeFallsBackToNameSelectorWhenIdIsMissing() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui, id: null), + Install, + Paths, + sessionId: "session-gui-name"); + + JsonObject session = SingleSession(composed); + Assert.Null(session["character"]!["id"]); + Assert.Equal("+Acdream", (string?)session["character"]!["name"]); + } + + [Fact] + public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays() + { + CharacterProfile character = Character(LaunchMode.Gui); + character.Plugins = []; + character.LoginCommands = []; + + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + character, + Install, + Paths, + sessionId: "session-empty-lists"); + + JsonObject session = SingleSession(composed); + Assert.False(session.ContainsKey("plugins")); + Assert.False(session.ContainsKey("loginCommands")); + } + + [Fact] + public void ProcessContentCarriesInstallRecordAndPathsIsAlwaysPresent() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui), + Install, + Paths, + sessionId: "session-content"); + + JsonObject root = ParseRoot(composed); + Assert.Equal(1, (int?)root["version"]); + JsonObject process = root["process"]!.AsObject(); + AssertKeys(process, "paths", "content"); + + // Paths is always present as an object; every member is omitted + // when unset (hosts resolve their own default ApplicationPathSet). + Assert.Empty(process["paths"]!.AsObject()); + + JsonObject content = process["content"]!.AsObject(); + AssertKeys(content, "datDirectory", "preparedAssetPath"); + Assert.Equal(Install.DatDirectory, (string?)content["datDirectory"]); + Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]); + } + + [Fact] + public void ComposedDocumentNeverContainsThePassword() + { + AccountProfile account = Account(); + + foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless }) + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + account, + Character(mode), + Install, + Paths, + sessionId: $"session-{mode}"); + + string json = SessionConfigComposer.Serialize(composed.Document); + Assert.DoesNotContain(account.Password, json, StringComparison.Ordinal); + } + } + + [Fact] + public void ComposeAndWriteWritesSessionJsonUnderTheExpectedPath() + { + string root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-composer-tests", + Guid.NewGuid().ToString("N")); + try + { + var paths = new ApplicationPathSet( + Path.Combine(root, "cfg"), + Path.Combine(root, "data"), + Path.Combine(root, "cache"), + null); + + ComposedSessionConfig composed = SessionConfigComposer.ComposeAndWrite( + Server(), + Account(), + Character(LaunchMode.Gui), + Install, + paths, + sessionId: "session-write"); + + string expectedPath = Path.Combine( + paths.CacheDirectory, "launcher", "sessions", "session-write", "session.json"); + Assert.Equal(expectedPath, composed.ConfigFilePath); + Assert.True(File.Exists(expectedPath)); + + string text = File.ReadAllText(expectedPath); + Assert.DoesNotContain(Account().Password, text, StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + private static JsonObject ParseRoot(ComposedSessionConfig composed) + { + string json = SessionConfigComposer.Serialize(composed.Document); + return JsonNode.Parse(json)!.AsObject(); + } + + private static JsonObject SingleSession(ComposedSessionConfig composed) + { + JsonObject root = ParseRoot(composed); + JsonArray sessions = root["sessions"]!.AsArray(); + return Assert.Single(sessions)!.AsObject(); + } + + /// + /// Asserts the object's property set is EXACTLY the given keys — no + /// more, no fewer — without depending on reflection-based member + /// enumeration order (only the presence/absence of each pinned- + /// contract key is a guarantee this slice makes). + /// + private static void AssertKeys(JsonObject obj, params string[] expectedKeys) + { + var actual = new HashSet(obj.Select(kv => kv.Key), StringComparer.Ordinal); + var expected = new HashSet(expectedKeys, StringComparer.Ordinal); + Assert.Equal(expected, actual); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs new file mode 100644 index 00000000..fdade48b --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs @@ -0,0 +1,43 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Tests.Profiles; + +public sealed class CharacterIdFormatTests +{ + [Fact] + public void ToHexStringFormatsEightDigitUppercaseWithPrefix() + { + Assert.Equal("0x5000000A", CharacterIdFormat.ToHexString(0x5000000Au)); + Assert.Equal("0x00000001", CharacterIdFormat.ToHexString(1u)); + } + + [Theory] + [InlineData("0x5000000A", 0x5000000Au)] + [InlineData("0x5000000a", 0x5000000Au)] + [InlineData("5000000A", 0x5000000Au)] + public void TryParseAcceptsWithAndWithoutPrefixAndCase(string text, uint expected) + { + Assert.True(CharacterIdFormat.TryParse(text, out uint id)); + Assert.Equal(expected, id); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-hex")] + public void TryParseRejectsNullEmptyOrNonHex(string? text) + { + Assert.False(CharacterIdFormat.TryParse(text, out uint id)); + Assert.Equal(0u, id); + } + + [Fact] + public void RoundTripsThroughToHexStringAndTryParse() + { + const uint original = 0x5000000Au; + string text = CharacterIdFormat.ToHexString(original); + Assert.True(CharacterIdFormat.TryParse(text, out uint parsed)); + Assert.Equal(original, parsed); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs new file mode 100644 index 00000000..db6a672a --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs @@ -0,0 +1,287 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Tests.Profiles; + +public sealed class LauncherProfileStoreTests : IDisposable +{ + private readonly string _root; + private readonly string _filePath; + + public LauncherProfileStoreTests() + { + _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-profile-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _filePath = Path.Combine(_root, "launcher-profiles.json"); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void LoadOnMissingFileYieldsEmptyDocumentWithoutTouchingDisk() + { + var store = new LauncherProfileStore(_filePath); + + bool loaded = store.Load(); + + Assert.False(loaded); + Assert.False(File.Exists(_filePath)); + Assert.Equal(1, store.Document.Version); + Assert.Empty(store.Document.Servers); + } + + [Fact] + public void AddServerThenSaveThenReloadRoundTrips() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.Save(); + + Assert.True(File.Exists(_filePath)); + + var reloaded = new LauncherProfileStore(_filePath); + reloaded.Load(); + + ServerProfile server = Assert.Single(reloaded.Document.Servers); + Assert.Equal("Local ACE", server.Name); + Assert.Equal("127.0.0.1", server.Host); + Assert.Equal(9000, server.Port); + Assert.Empty(server.Accounts); + } + + [Fact] + public void AddServerRejectsDuplicateName() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + var ex = Assert.Throws( + () => store.AddServer("Local ACE", "127.0.0.1", 9001)); + Assert.Contains("Local ACE", ex.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(65536)] + [InlineData(-1)] + public void AddServerRejectsOutOfRangePort(int port) + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + + Assert.Throws( + () => store.AddServer("Local ACE", "127.0.0.1", port)); + } + + [Fact] + public void EditServerRenamesAndUpdatesHostAndPort() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + store.EditServer("Local ACE", newName: "Home ACE", newHost: "10.0.0.5", newPort: 9001); + + ServerProfile server = Assert.Single(store.Document.Servers); + Assert.Equal("Home ACE", server.Name); + Assert.Equal("10.0.0.5", server.Host); + Assert.Equal(9001, server.Port); + } + + [Fact] + public void EditServerOnUnknownNameThrows() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + + Assert.Throws( + () => store.EditServer("Nope", newHost: "1.2.3.4")); + } + + [Fact] + public void RemoveServerRemovesIt() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + store.RemoveServer("Local ACE"); + + Assert.Empty(store.Document.Servers); + } + + [Fact] + public void AddEditRemoveAccountRoundTrip() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + store.AddAccount("Local ACE", "testaccount", "testpassword"); + AccountProfile account = Assert.Single( + store.Document.Servers.Single().Accounts); + Assert.Equal("testaccount", account.Account); + Assert.Equal("testpassword", account.Password); + + store.EditAccount( + "Local ACE", + "testaccount", + newAccount: "renamed", + newPassword: "newpass"); + account = Assert.Single(store.Document.Servers.Single().Accounts); + Assert.Equal("renamed", account.Account); + Assert.Equal("newpass", account.Password); + + store.RemoveAccount("Local ACE", "renamed"); + Assert.Empty(store.Document.Servers.Single().Accounts); + } + + [Fact] + public void AddAccountRejectsDuplicateAccountOnSameServer() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "pw"); + + Assert.Throws( + () => store.AddAccount("Local ACE", "testaccount", "pw2")); + } + + [Fact] + public void EditCharacterUpdatesLaunchModePluginsAndLoginCommandsOnly() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "pw"); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + + store.EditCharacter( + "Local ACE", + "testaccount", + "+Acdream", + launchMode: LaunchMode.Headless, + plugins: ["ExamplePlugin"], + loginCommands: ["/tell someone, hi"]); + + CharacterProfile character = Assert.Single( + store.Document.Servers.Single().Accounts.Single().Characters); + Assert.Equal(LaunchMode.Headless, character.LaunchMode); + Assert.Equal(["ExamplePlugin"], character.Plugins); + Assert.Equal(["/tell someone, hi"], character.LoginCommands); + Assert.Equal("0x5000000A", character.Id); + } + + [Fact] + public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "testpassword"); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + store.EditCharacter( + "Local ACE", + "testaccount", + "+Acdream", + launchMode: LaunchMode.Gui, + plugins: ["ExamplePlugin"], + loginCommands: ["/vt start"]); + store.Save(); + + // Direct proof of the on-disk enum casing — a round-trip alone + // could mask a PascalCase regression if the reader ever became + // case-insensitive on enum values. + string text = File.ReadAllText(_filePath); + Assert.Contains("\"launchMode\":\"gui\"", text.Replace(" ", string.Empty)); + + var reloaded = new LauncherProfileStore(_filePath); + reloaded.Load(); + + ServerProfile server = Assert.Single(reloaded.Document.Servers); + AccountProfile account = Assert.Single(server.Accounts); + CharacterProfile character = Assert.Single(account.Characters); + Assert.Equal("+Acdream", character.Name); + Assert.Equal("0x5000000A", character.Id); + Assert.Equal(LaunchMode.Gui, character.LaunchMode); + Assert.Equal(["ExamplePlugin"], character.Plugins); + Assert.Equal(["/vt start"], character.LoginCommands); + } + + [Fact] + public void LoadRejectsUnsupportedVersion() + { + File.WriteAllText(_filePath, """{"version":2,"servers":[]}"""); + var store = new LauncherProfileStore(_filePath); + + Assert.Throws(() => store.Load()); + } + + [Fact] + public void LoadRejectsUnmappedMembersStrictly() + { + File.WriteAllText( + _filePath, + """{"version":1,"servers":[],"unexpectedField":true}"""); + var store = new LauncherProfileStore(_filePath); + + Assert.Throws(() => store.Load()); + } + + [Fact] + public void SaveWritesCamelCaseJson() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.Save(); + + string text = File.ReadAllText(_filePath); + Assert.Contains("\"version\"", text); + Assert.Contains("\"servers\"", text); + Assert.Contains("\"host\"", text); + Assert.DoesNotContain("\"Version\"", text); + Assert.DoesNotContain("\"Servers\"", text); + } + + [Fact] + public void SaveSetsOwnerOnlyPermissionsOnLinux() + { + // Linux-conditional: 0600 is a Linux-only hygiene step (spec §5, + // decisions log item "Windows profile-file permissions"). A no-op + // pass on Windows/macOS, matching the repo's established + // OperatingSystem.IsLinux() early-return pattern (e.g. + // HeadlessCredentialResolverTests.LinuxRejectsGroupOrOtherCredentialPermissions). + if (!OperatingSystem.IsLinux()) + return; + + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "testpassword"); + store.Save(); + + UnixFileMode mode = File.GetUnixFileMode(_filePath); + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + mode); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs new file mode 100644 index 00000000..9b967ea9 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs @@ -0,0 +1,146 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Tests.Profiles; + +/// +/// The roster-merge matrix (Campaign LA plan §LA3 acceptance): a new +/// character, an existing character keeping its user settings, and a +/// character absent from a later roster snapshot being retained +/// (possibly pending-delete). +/// +public sealed class RosterMergeTests +{ + private static LauncherProfileStore NewStoreWithServerAndAccount() + { + var store = new LauncherProfileStore( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json")); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "testpassword"); + return store; + } + + [Fact] + public void FirstMergeAddsNewCharactersWithDefaultLaunchModeGuiSelect() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + + store.MergeRoster( + "Local ACE", + "testaccount", + [ + new CharacterRosterEntry(0x5000000A, "+Acdream", 0), + new CharacterRosterEntry(0x5000000B, "+Second", 0), + ]); + + List characters = + store.Document.Servers.Single().Accounts.Single().Characters; + Assert.Equal(2, characters.Count); + + CharacterProfile first = characters.Single(c => c.Name == "+Acdream"); + Assert.Equal("0x5000000A", first.Id); + Assert.Equal(LaunchMode.GuiSelect, first.LaunchMode); + Assert.Empty(first.Plugins); + Assert.Empty(first.LoginCommands); + + CharacterProfile second = characters.Single(c => c.Name == "+Second"); + Assert.Equal("0x5000000B", second.Id); + Assert.Equal(LaunchMode.GuiSelect, second.LaunchMode); + } + + [Fact] + public void SecondMergePreservesUserSettingsOnAnExistingCharacter() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + store.EditCharacter( + "Local ACE", + "testaccount", + "+Acdream", + launchMode: LaunchMode.Headless, + plugins: ["ExamplePlugin"], + loginCommands: ["/vt start"]); + + // A later probe reports the same character again (same id), with + // a renamed display — settings must survive untouched. + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + + CharacterProfile character = Assert.Single( + store.Document.Servers.Single().Accounts.Single().Characters); + Assert.Equal(LaunchMode.Headless, character.LaunchMode); + Assert.Equal(["ExamplePlugin"], character.Plugins); + Assert.Equal(["/vt start"], character.LoginCommands); + } + + [Fact] + public void MergeUpdatesNameWhenIdMatchesButDisplayNameChanged() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+OldName", 0)]); + + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+NewName", 0)]); + + CharacterProfile character = Assert.Single( + store.Document.Servers.Single().Accounts.Single().Characters); + Assert.Equal("+NewName", character.Name); + Assert.Equal("0x5000000A", character.Id); + } + + [Fact] + public void CharacterAbsentFromALaterRosterSnapshotIsRetained() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + store.MergeRoster( + "Local ACE", + "testaccount", + [ + new CharacterRosterEntry(0x5000000A, "+Acdream", 0), + new CharacterRosterEntry(0x5000000B, "+PendingDelete", 1), + ]); + + // A later probe's roster only reports one of the two — e.g. the + // other was deleted and is now in ACE's grace window / a + // partial snapshot. The store never removes rows on the + // caller's behalf. + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + + List characters = + store.Document.Servers.Single().Accounts.Single().Characters; + Assert.Equal(2, characters.Count); + Assert.Contains(characters, c => c.Name == "+Acdream"); + Assert.Contains(characters, c => c.Name == "+PendingDelete"); + } + + [Fact] + public void MergeThrowsForUnknownServerOrAccount() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + + Assert.Throws( + () => store.MergeRoster( + "Nope", + "testaccount", + [new CharacterRosterEntry(1, "x", 0)])); + + Assert.Throws( + () => store.MergeRoster( + "Local ACE", + "nope", + [new CharacterRosterEntry(1, "x", 0)])); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs new file mode 100644 index 00000000..ef98becd --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -0,0 +1,125 @@ +using AcDream.Launcher.Core.Status; + +namespace AcDream.Launcher.Core.Tests.Status; + +public sealed class StatusEventParserTests +{ + [Fact] + public void ParsesStarted() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"started","t":"2026-08-14T12:00:00Z","sessionId":"s1"}"""); + + var started = Assert.IsType(e); + Assert.Equal(1, started.V); + Assert.Equal("started", started.E); + Assert.Equal("s1", started.SessionId); + Assert.Equal( + DateTimeOffset.Parse("2026-08-14T12:00:00Z"), + started.T); + } + + [Fact] + public void ParsesConnected() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"connected","t":"2026-08-14T12:00:01Z","sessionId":"s1"}"""); + Assert.IsType(e); + } + + [Fact] + public void ParsesCharacterListWithMultipleCharacters() + { + var e = StatusEventParser.Parse( + """ + {"v":1,"e":"characterList","t":"2026-08-14T12:00:02Z","sessionId":"s1", + "accountName":"testaccount","slotCount":6, + "characters":[ + {"id":1342177290,"name":"+Acdream","secondsGreyedOut":0}, + {"id":1342177291,"name":"+Second","secondsGreyedOut":1} + ]} + """); + + var list = Assert.IsType(e); + Assert.Equal("testaccount", list.AccountName); + Assert.Equal(6, list.SlotCount); + Assert.Equal(2, list.Characters.Count); + Assert.Equal(1342177290u, list.Characters[0].Id); + Assert.Equal("+Acdream", list.Characters[0].Name); + Assert.Equal(0, list.Characters[0].SecondsGreyedOut); + Assert.Equal(1342177291u, list.Characters[1].Id); + Assert.Equal(1, list.Characters[1].SecondsGreyedOut); + } + + [Fact] + public void ParsesEnteredWorld() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"enteredWorld","t":"2026-08-14T12:00:03Z","sessionId":"s1","characterId":1342177290,"characterName":"+Acdream"}"""); + + var entered = Assert.IsType(e); + Assert.Equal(1342177290u, entered.CharacterId); + Assert.Equal("+Acdream", entered.CharacterName); + } + + [Fact] + public void ParsesPluginLoadedAndPluginFailed() + { + var loaded = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"pluginLoaded","t":"2026-08-14T12:00:04Z","sessionId":"s1","plugin":"ExamplePlugin"}""")); + Assert.Equal("ExamplePlugin", loaded.Plugin); + + var failed = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"pluginFailed","t":"2026-08-14T12:00:05Z","sessionId":"s1","plugin":"BadPlugin","error":"boom"}""")); + Assert.Equal("BadPlugin", failed.Plugin); + Assert.Equal("boom", failed.Error); + } + + [Fact] + public void ParsesDisconnectedAndExited() + { + var disconnected = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"disconnected","t":"2026-08-14T12:00:06Z","sessionId":"s1","reason":"serverClosed"}""")); + Assert.Equal("serverClosed", disconnected.Reason); + + var exited = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"exited","t":"2026-08-14T12:00:07Z","sessionId":"s1","code":0,"reason":"graceful"}""")); + Assert.Equal(0, exited.Code); + Assert.Equal("graceful", exited.Reason); + } + + [Fact] + public void UnknownEValueSurfacesAsUnknownEventRatherThanThrowing() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"someFutureEvent","t":"2026-08-14T12:00:08Z","sessionId":"s1","extra":true}"""); + + var unknown = Assert.IsType(e); + Assert.Equal("someFutureEvent", unknown.E); + Assert.Equal("s1", unknown.SessionId); + Assert.Contains("someFutureEvent", unknown.RawJson); + } + + [Fact] + public void MalformedJsonSurfacesAsUnknownEventRatherThanThrowing() + { + var e = StatusEventParser.Parse("{not json"); + + Assert.IsType(e); + } + + [Fact] + public void KnownEValueWithMissingRequiredFieldSurfacesAsUnknownEventRatherThanThrowing() + { + // characterList without "characters" — a shape mismatch, not + // just an unrecognized e value. + var e = StatusEventParser.Parse( + """{"v":1,"e":"characterList","t":"2026-08-14T12:00:09Z","sessionId":"s1","accountName":"a","slotCount":6}"""); + + Assert.IsType(e); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs new file mode 100644 index 00000000..f908ec06 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -0,0 +1,172 @@ +using System.Text; +using AcDream.Launcher.Core.Status; + +namespace AcDream.Launcher.Core.Tests.Status; + +public sealed class StatusFileTailerTests : IDisposable +{ + private readonly string _root; + private readonly string _path; + + public StatusFileTailerTests() + { + _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-tailer-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _path = Path.Combine(_root, "status.jsonl"); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void ReturnsNoEventsWhenTheFileDoesNotExistYet() + { + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Empty(events); + } + + [Fact] + public void ReturnsNoEventsWhenNothingHasBeenAppendedSinceTheLastPoll() + { + AppendShared(Line("started", "s1")); + var tailer = new StatusFileTailer(_path); + Assert.Single(tailer.ReadNewEvents()); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Empty(events); + } + + [Fact] + public void ReadsMultipleCompleteLinesInOnePoll() + { + AppendShared(Line("started", "s1") + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(2, events.Count); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + [Fact] + public void TolerateAPartialLastLineAndCompletesItOnALaterPoll() + { + string full = Line("started", "s1"); + int splitAt = full.Length - 10; // cut mid-object, before the closing brace/newline + AppendShared(full[..splitAt]); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList firstPoll = tailer.ReadNewEvents(); + Assert.Empty(firstPoll); + + AppendShared(full[splitAt..]); + IReadOnlyList secondPoll = tailer.ReadNewEvents(); + + StatusEvent onlyEvent = Assert.Single(secondPoll); + Assert.IsType(onlyEvent); + } + + [Fact] + public void APartialLineFollowedByAFullLineOnlyEmitsTheCompleteOne() + { + AppendShared(Line("started", "s1")); + string partial = """{"v":1,"e":"connected","t":"2026-08-14T12:00:00Z","sessionId":"s1"""; // no closing + AppendShared(partial); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + StatusEvent onlyEvent = Assert.Single(events); + Assert.IsType(onlyEvent); + + // Completing the second line on a later poll produces exactly + // one more event, proving the partial bytes were retained (not + // dropped and not double-counted). + AppendShared("\"}\n"); + IReadOnlyList secondPoll = tailer.ReadNewEvents(); + StatusEvent completed = Assert.Single(secondPoll); + Assert.IsType(completed); + } + + [Fact] + public void SkipsBlankLines() + { + AppendShared("\n" + Line("started", "s1") + "\n" + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(2, events.Count); + } + + [Fact] + public void ReadsWithAWriterHoldingTheFileOpenForAppend() + { + // Share-tolerant reads: the writer's handle stays open the whole + // time (FileShare.ReadWrite on both sides), matching a live host + // process appending status.jsonl while the launcher tails it. + using var writer = new FileStream( + _path, + FileMode.Create, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + var tailer = new StatusFileTailer(_path); + + byte[] first = Encoding.UTF8.GetBytes(Line("started", "s1")); + writer.Write(first, 0, first.Length); + writer.Flush(); + + IReadOnlyList firstPoll = tailer.ReadNewEvents(); + Assert.Single(firstPoll); + + byte[] second = Encoding.UTF8.GetBytes(Line("connected", "s1")); + writer.Write(second, 0, second.Length); + writer.Flush(); + + IReadOnlyList secondPoll = tailer.ReadNewEvents(); + Assert.Single(secondPoll); + Assert.IsType(secondPoll[0]); + } + + [Fact] + public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced() + { + AppendShared(Line("started", "s1") + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + Assert.Equal(2, tailer.ReadNewEvents().Count); + + File.Delete(_path); + AppendShared(Line("started", "s2")); + + IReadOnlyList events = tailer.ReadNewEvents(); + StatusEvent onlyEvent = Assert.Single(events); + Assert.Equal("s2", onlyEvent.SessionId); + } + + private static string Line(string e, string sessionId) => + $$"""{"v":1,"e":"{{e}}","t":"2026-08-14T12:00:00Z","sessionId":"{{sessionId}}"}""" + "\n"; + + private void AppendShared(string text) + { + using var stream = new FileStream( + _path, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + byte[] bytes = Encoding.UTF8.GetBytes(text); + stream.Write(bytes, 0, bytes.Length); + } +} From 205b09cf32b0a7f86175647f4776571a4c6d4ef0 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:50:41 +0200 Subject: [PATCH 009/138] =?UTF-8?q?docs:=20Campaign=20LA=20ledger=20?= =?UTF-8?q?=E2=80=94=20LA3=20implemented=20(37d74e44),=20LA7a=20implemente?= =?UTF-8?q?d=20(6a32f375),=20both=20reviews=20in=20flight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index cf3e6476..7362ee4b 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -407,11 +407,11 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields | | LA2 | — | | | | -| LA3 | in flight (Sonnet, isolated worktree) | | | pinned contract shared with LA1 | +| LA3 | implemented (`37d74e44`, campaign-la3); Opus review in flight | `37d74e44` | review in flight | 71/71 Windows AND 71/71 WSL (0600 test real on Linux); reviewer checking contract token-fidelity + tailer fix | | LA4 | — | | | | | LA5 | — | | | | | LA6 | — | | | | -| LA7 | — | | | | +| LA7 | LA7a implemented (`6a32f375`, campaign-la7a); Opus retail-lens review in flight. LA7b (state+flow) waits on LA1 | `6a32f375` | review in flight | 46 new byte-exact tests; Core.Net 951/0/0. Reviewer verifying: conditional 0xF643 parse, retail 26-member charError enum, uninit-edx artifact proof | | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | From 4338b1c1f37135c635f6d5adc38c57c12f8e0f23 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:01:52 +0200 Subject: [PATCH 010/138] =?UTF-8?q?fix(net):=20Campaign=20LA=20LA7a=20revi?= =?UTF-8?q?ew=20fixes=20=E2=80=94=20AD-97=20register=20row,=20corrected=20?= =?UTF-8?q?restore=20justification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Opus retail-lens review decoded the PDB-paired binary at CPlayerSystem::RestoreCharacter@0x0055d760 and refuted the uninitialized-edx justification: the two extra arguments are real push imm32 of a constant PStringBase (BN mis-renders them, but they pack to >=4 bytes each), so retail 0xF7D9 is >=16 bytes where ours is 8. The guid-only CODE stands (ACE reads only the guid; holtburger consensus) but it is an adaptation, not a corrected decompile — filed as divergence register AD-97 and the doc comment now states the true mechanism. Also from the review: the 0xF643 conditional-parse doc now names BOTH ACE flag-only failure branches (NameInUse + Corrupt); CharacterError 0x08 doc corrected (ACE misnames it ServerCrash2 — the port corrects an ACE misnaming; ACE omits three values, not four); LA7b hazard notes added (ACE silent no-reply on unknown restore guid; retail SendToLogon vs SendToControl routing; NumErrors never rendered); two review-nit tests (flag=0 Undef flag-only, non-Ok body with trailing bytes ignored). Core.Net suite: 953 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 1 + .../Messages/CharacterDelete.cs | 7 ++ .../Messages/CharacterError.cs | 23 +++++-- .../Messages/CharacterRestore.cs | 64 +++++++++++-------- .../Messages/CharacterRestoreTests.cs | 39 +++++++++++ 5 files changed, 100 insertions(+), 34 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index fc668ffe..7fd0b897 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -189,6 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | +| 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` (4 of its 5 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 | --- diff --git a/src/AcDream.Core.Net/Messages/CharacterDelete.cs b/src/AcDream.Core.Net/Messages/CharacterDelete.cs index 6fa07037..f02caa4d 100644 --- a/src/AcDream.Core.Net/Messages/CharacterDelete.cs +++ b/src/AcDream.Core.Net/Messages/CharacterDelete.cs @@ -43,6 +43,13 @@ namespace AcDream.Core.Net.Messages; /// /// /// +/// Routing note for LA7b: retail transmits this request via +/// Proto_UI::SendToLogon (the restore request rides +/// SendToControl); ACE sends its acknowledgement and the follow-up +/// refreshed CharacterList on GameMessageGroup.UIQueue. +/// +/// +/// /// After the ack, ACE immediately follows with a fresh /// so the roster reflects the character's new pending-delete state /// (CharacterHandler.CharacterDelete, diff --git a/src/AcDream.Core.Net/Messages/CharacterError.cs b/src/AcDream.Core.Net/Messages/CharacterError.cs index 7d2c70ce..40589d72 100644 --- a/src/AcDream.Core.Net/Messages/CharacterError.cs +++ b/src/AcDream.Core.Net/Messages/CharacterError.cs @@ -43,11 +43,15 @@ namespace AcDream.Core.Net.Messages; /// It is a strict superset of ACE's ACE.Server.Network.Enum.CharacterError /// (references/ACE/Source/ACE.Server/Network/Enum/CharacterError.cs): /// retail additionally names 0x2 (LoggedOn), 0x7 (NoPremade), -/// 0x8 (AccountInUse), and 0x16 (CharacterIsBooted), none of -/// which ACE's server ever sends but all of which retail's client can -/// receive from a genuine retail server — per the project's -/// property-enum-divergence lesson, we port the complete oracle, not just -/// what today's one server implementation emits. ACE's per-value doc +/// and 0x16 (CharacterIsBooted) — three values ACE omits entirely, +/// none of which ACE's server ever sends but all of which retail's client +/// can receive from a genuine retail server. At 0x8 the port additionally +/// CORRECTS an ACE misnaming: ACE defines 0x8 as ServerCrash2 with a +/// doc comment duplicating 0x4's ID_CHAR_ERROR_SERVER_CRASH text, +/// but retail's header names 0x8 CHAR_ERROR_ACCOUNT_IN_USE — the +/// header wins. Per the project's property-enum-divergence lesson, we port +/// the complete oracle, not just what today's one server implementation +/// emits. ACE's per-value doc /// comments (themselves sourced from the client's ID_CHAR_ERROR_* /// string table) are folded in below where they exist. One retail member, /// FORCE_charError_32_BIT = 0x7FFFFFFF, is a compiler @@ -111,7 +115,11 @@ public static class CharacterError /// 0x07 — CHAR_ERROR_NO_PREMADE. Retail-only; no ACE member. NoPremade = 0x07, - /// 0x08 — CHAR_ERROR_ACCOUNT_IN_USE. Retail-only; no ACE member. + /// + /// 0x08 — CHAR_ERROR_ACCOUNT_IN_USE. ACE misnames this value + /// ServerCrash2 (its doc comment duplicates 0x04's text); + /// retail's header is the authority. See the class doc comment. + /// AccountInUse = 0x08, /// @@ -228,7 +236,8 @@ public static class CharacterError /// sentinel (the array-bound idiom, one past the last real code) — /// never sent on the wire as an actual error. Kept for verbatim /// completeness of the enum range; do not treat a received 0x19 - /// as meaningful. + /// as meaningful, and LA7b's error-to-string mapping must not + /// render it as a user-facing message. /// NumErrors = 0x19, } diff --git a/src/AcDream.Core.Net/Messages/CharacterRestore.cs b/src/AcDream.Core.Net/Messages/CharacterRestore.cs index a40858e2..794cc50d 100644 --- a/src/AcDream.Core.Net/Messages/CharacterRestore.cs +++ b/src/AcDream.Core.Net/Messages/CharacterRestore.cs @@ -8,27 +8,36 @@ namespace AcDream.Core.Net.Messages; /// (opcode 0xF643). /// /// -/// Request — guid-only, by reference consensus. The decompiled call -/// site (Proto_UI::SendAdminRestoreCharacter at 0x00546cf0, -/// declared with three parameters — a u32 and two PStringBase<char> -/// pointers — and packing two strings after the u32) LOOKS like it sends -/// guid + two strings. It does not: its only real caller, -/// CPlayerSystem::RestoreCharacter at 0x0055d760, declares -/// class PStringBase<char>* edx; as a local and passes it -/// straight through UNINITIALIZED as the second argument, and passes -/// this (a CPlayerSystem*, not a string) as the third. Both -/// are textbook decompiler register-corruption artifacts (uninitialized -/// register reuse + a mistyped extra parameter from an over-declared -/// callee signature), not real arguments the real call site ever -/// supplied. ACE +/// Request — guid-only, an ADAPTATION (register row AD-97). Retail +/// really does send more than the guid. The PDB-paired binary at +/// CPlayerSystem::RestoreCharacter@0x0055d760 is 26 bytes: +/// push 0x008173B4; push 0x008173B4; push guid; +/// call Proto_UI::SendAdminRestoreCharacter@0x00546cf0 — two REAL +/// constant PStringBase<char>* arguments (Binary Ninja renders +/// them as an uninitialized edx local and this; that +/// rendering is the artifact, the two push imm32 are not). +/// SendAdminRestoreCharacter packs both +/// (PStringBase::Pack@0x004fc6f0 emits ≥4 bytes even for an empty +/// string), so retail's request is ≥16 bytes where ours is 8. We send +/// guid-only because ACE /// (CharacterHandler.CharacterRestore, -/// ACE.Server/Network/Handlers/CharacterHandler.cs:331-385, reads -/// only ReadUInt32()) and holtburger +/// ACE.Server/Network/Handlers/CharacterHandler.cs:331-385) reads +/// only ReadUInt32() and ignores any tail, and holtburger /// (holtburger-protocol/src/messages/character/types.rs::CharacterRestoreRequestData, -/// guid-only) independently agree on guid-only. We follow the two -/// independent, uncorrupted references (design spec §11 item 4 — wire -/// consensus, no divergence-register row needed: this isn't a deviation -/// from retail, it's picking the correct reading of a corrupted decompile). +/// sent from a real client command path) ships guid-only against ACE +/// successfully. The omitted tail is a recorded retail deviation — +/// divergence register AD-97. +/// +/// +/// +/// LA7b hazards. (1) ACE's restore handler has a SILENT no-reply +/// path: an unknown guid hits +/// Characters.SingleOrDefault(...) == null → return; — no 0xF643, +/// no 0xF659. Selection state must never await a restore reply +/// unconditionally. (2) Routing: ACE sends the response on +/// GameMessageGroup.UIQueue; retail transmits the request via +/// Proto_UI::SendToControl (the delete request goes via +/// SendToLogon) — relevant when LA7b picks the outbound queue. /// /// /// @@ -55,12 +64,13 @@ namespace AcDream.Core.Net.Messages; /// /// /// -/// But retail's CharacterRestore handler can ALSO reply on this same +/// But ACE's CharacterRestore handler can ALSO reply on this same /// opcode via the character-CREATE response path when restore itself fails -/// (e.g. SendCharacterCreateResponse(session, CharacterGenerationVerificationResponse.NameInUse) -/// when the freed name collides) — that shape is flag-only, with NO -/// trailing fields (GameMessageCharacterCreateResponse.cs: the guid / -/// name / trailing u32 are only written if (response == ... .Ok)). +/// — TWO real branches: NameInUse (the freed name collided) and +/// Corrupt (SaveCharacter returned false). Both shapes are +/// flag-only, with NO trailing fields +/// (GameMessageCharacterCreateResponse.cs: the guid / name / +/// trailing u32 are only written if (response == ... .Ok)). /// mirrors that conditionality: the trailing three /// fields are read only when verificationFlag == 1. Because the two /// message families are wire-identical when they collide, a caller cannot @@ -95,9 +105,9 @@ public static class CharacterRestore /// /// Build the body bytes for an outbound CharacterRestore request. - /// Layout: opcode(4) + characterGuid(4). Guid-only — see the class doc - /// comment for why the decompiled call site's apparent extra strings - /// are not real. + /// Layout: opcode(4) + characterGuid(4). Guid-only — an adaptation of + /// retail's ≥16-byte shape; see the class doc comment and divergence + /// register AD-97. /// public static byte[] BuildRequestBody(uint characterGuid) { diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs index 432b425c..1b81b5ac 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs @@ -74,6 +74,45 @@ public sealed class CharacterRestoreTests Assert.Null(parsed.SecondsGreyedOut); } + [Fact] + public void Parse_UndefFlagZero_FlagOnlyBody_LeavesTrailingFieldsNull() + { + // LA7a review test-coverage nit: flag 0 (Undef) is a non-Ok value + // distinct from the NameInUse case — the conditional must treat it + // as flag-only too. + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode) + .Write(0u); // CharacterGenerationVerificationResponse.Undef + + CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray()); + + Assert.Equal(0u, parsed.VerificationFlag); + Assert.False(parsed.IsOk); + Assert.Null(parsed.Guid); + Assert.Null(parsed.Name); + Assert.Null(parsed.SecondsGreyedOut); + } + + [Fact] + public void Parse_NonOkBodyWithTrailingBytes_IgnoresRatherThanMisreads() + { + // LA7a review test-coverage nit: a non-Ok body that DOES carry + // trailing bytes (unknown server variant / padding) must not be + // misread as character fields — the conditional stops at the flag + // and the extra bytes are ignored. + var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode) + .Write(3u) // NameInUse + .Write(0xDEADBEEFu) + .Write(0x12345678u); + + CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray()); + + Assert.Equal(3u, parsed.VerificationFlag); + Assert.False(parsed.IsOk); + Assert.Null(parsed.Guid); + Assert.Null(parsed.Name); + Assert.Null(parsed.SecondsGreyedOut); + } + [Fact] public void Parse_WrongOpcode_Throws() { From 0bcc7ba3a3bb3e94794f0d2735bb5e9a7b06780a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:02:20 +0200 Subject: [PATCH 011/138] =?UTF-8?q?docs:=20Campaign=20LA=20=E2=80=94=20spe?= =?UTF-8?q?c=20=C2=A711.4=20corrected=20per=20LA7a=20review;=20LA7b=20haza?= =?UTF-8?q?rds=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec seeded the wrong claim (restore extra strings = decompiler artifact, no register row needed); the LA7a Opus review decoded the PDB-paired binary and showed the two constant-string arguments are real, making our guid-only request an adaptation — AD-97 filed on the LA7a branch. Plan LA7 now carries the review-surfaced LA7b hazards (ACE silent no-reply restore path, SendToLogon/SendToControl routing, NumErrors sentinel). Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 9 +++++++++ .../specs/2026-08-14-launcher-campaign-design.md | 16 ++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 7362ee4b..b362f863 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -312,6 +312,15 @@ delete/restore/error. selector-carrying/headless sessions. Selection feeds the existing `EnterWorld` path unchanged. +LA7b hazards carried from the LA7a review (2026-08-14): ACE's restore +handler has a SILENT no-reply path (unknown guid → `return;`, no 0xF643, +no 0xF659) — selection state must never block awaiting a restore reply; +outbound routing is delete via retail's SendToLogon, restore via +SendToControl, ACE replies on UIQueue; `charError.NumErrors` (0x19) is an +enum-range sentinel and must never render as a user-facing message. +Register row AD-97 (guid-only restore request, an adaptation) rides the +LA7a branch. + **Acceptance:** message round-trip tests against ACE's serializer shapes; selection-state tests (greyed transitions, delete→list-refresh, restore, error surfacing); no-selector stop + enter flow tests; suites green. diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index a68527c5..42360f1d 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -334,14 +334,18 @@ preview would be a deliberate divergence we are NOT taking. behavior symbols are in §7; the DataID hides behind `GetDIDByEnum` enum-table 5 (enum `0x10000005`) — dump the table from installed DATs in the screen slice. -4. RESOLVED by recon: delete = 0xF655 account+slot (ack opcode-only, then - fresh CharacterList); restore = 0xF7D9 guid → 0xF643 response; ACE's +4. RESOLVED by recon, CORRECTED by the LA7a Opus review (2026-08-14): + delete = 0xF655 account+slot (ack opcode-only, then fresh + CharacterList); restore = 0xF7D9 → 0xF643 response; ACE's `secondsSincePendingDelete` computes to a constant 1 during the grace window (ACE quirk — treat any non-zero as "pending delete", don't - display it as a countdown). One decomp ambiguity (whether retail's - restore carries two extra strings) is noted as a likely decompiler - artifact — ACE + holtburger agree on guid-only; follow them and note - the register row is NOT needed (wire consensus, no behavior change). + display it as a countdown). The restore "two extra strings" question + resolved AGAINST the earlier reading: the PDB-paired binary shows two + REAL constant-string arguments (the decompiler mangled their + rendering, not their existence), so retail's request is ≥16 bytes and + our guid-only 8-byte form is an ADAPTATION — register row AD-97, + filed with the LA7a fix round. ACE reads only the guid; holtburger + ships guid-only successfully. 5. Whether retail rendered a render-loop-level background scene behind the character-management UI — pseudo-C only proves the UI class owns no viewport. Resolve in the screen slice (user memory of retail + the From db9ad53c1c22124db902f92af08272ab0fdfd5ef Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:04:32 +0200 Subject: [PATCH 012/138] =?UTF-8?q?docs:=20Campaign=20LA=20=E2=80=94=20pin?= =?UTF-8?q?ned=20launch-contract=20schema=20COMMITTED=20into=20plan=20LA1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LA3 Opus review process note was right: the contract both sides implement lived only in orchestrator prompts, which is exactly the drift mode the pin exists to prevent (and it produced the paths-key CRITICAL). The schema, field rules, probe-mode discriminator, and status vocabulary are now a binding plan section; amendments change this text first, implementations second. Ledger: LA3 fix round dispatched. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 65 ++++- .../Composition/SessionPlayerComposition.cs | 13 +- .../Composition/SessionStartComposition.cs | 10 +- .../Configuration/SessionConfiguration.cs | 142 +++++++++++ .../SessionConfigurationException.cs | 18 ++ .../SessionConfigurationLoader.cs | 158 ++++++++++++ .../Credentials/AppCredentialResolver.cs | 155 ++++++++++++ .../Credentials/AppCredentialSecret.cs | 65 +++++ .../Net/LiveSessionRuntimeFactory.cs | 20 +- .../Platform/GraphicalHostPlatformServices.cs | 16 ++ src/AcDream.App/Program.cs | 127 +++++++++- src/AcDream.App/Rendering/GameWindow.cs | 37 ++- src/AcDream.App/RuntimeOptions.cs | 97 +++++++- .../Configuration/HeadlessConfiguration.cs | 32 +++ .../HeadlessConfigurationLoader.cs | 36 +++ .../Hosting/HeadlessSessionHost.cs | 59 ++++- .../Session/LiveSessionController.cs | 60 +++++ .../Session/LiveSessionHost.cs | 19 +- .../Session/LiveSessionLifecycleHost.cs | 5 + .../Session/SessionStatusWriter.cs | 162 +++++++++++++ .../RuntimeOptionsSessionConfigTests.cs | 148 ++++++++++++ .../SessionConfigurationSharedFixtureTests.cs | 225 ++++++++++++++++++ .../Credentials/AppCredentialResolverTests.cs | 167 +++++++++++++ .../LiveSessionShutdownIntegrationTests.cs | 1 + .../Runtime/CurrentGameRuntimeAdapterTests.cs | 4 +- ...dlessSessionEventRouteRetryPendingTests.cs | 4 +- .../HeadlessSessionHostTests.cs | 98 +++++++- .../SessionConfigurationSharedFixtureTests.cs | 206 ++++++++++++++++ .../DirectGameRuntimeCommandAdapterTests.cs | 8 +- .../Session/LiveSessionControllerTests.cs | 44 +++- .../Session/LiveSessionHostTests.cs | 8 +- .../Session/LiveSessionLifecycleHostTests.cs | 6 +- ...imeAcceptedPositionDriveControllerTests.cs | 4 +- ...RuntimeLiveEntitySessionControllerTests.cs | 4 +- .../RuntimeLiveSessionNoWindowTests.cs | 6 +- .../Session/SessionStatusWriterTests.cs | 177 ++++++++++++++ .../Support/NoWindowGameRuntimeHost.cs | 11 +- .../session-config-shared-fixture.json | 20 ++ 38 files changed, 2397 insertions(+), 40 deletions(-) create mode 100644 src/AcDream.App/Configuration/SessionConfiguration.cs create mode 100644 src/AcDream.App/Configuration/SessionConfigurationException.cs create mode 100644 src/AcDream.App/Configuration/SessionConfigurationLoader.cs create mode 100644 src/AcDream.App/Credentials/AppCredentialResolver.cs create mode 100644 src/AcDream.App/Credentials/AppCredentialSecret.cs create mode 100644 src/AcDream.Runtime/Session/SessionStatusWriter.cs create mode 100644 tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs create mode 100644 tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs create mode 100644 tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs create mode 100644 tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs create mode 100644 tests/Fixtures/campaign-la/session-config-shared-fixture.json diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index b362f863..9988b8c4 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -108,6 +108,69 @@ referencing only `AcDream.Platform`. ## LA1 — launch contract (client side) +### Pinned launch-contract schema (v1, BINDING — committed per LA3 review) + +This text is the single source of truth for the launcher↔host file +contract. Both host readers (LA1), the composer (LA3), and the probe +loader (LA2) implement EXACTLY this; any change is an amendment to THIS +section first, implementations second. The LA1+LA3 merge adds a +cross-assembly test feeding a composer-produced document to both host +loaders — that test is the seam's permanent enforcement. + +Session-config document (System.Text.Json, camelCase, +`UnmappedMemberHandling.Disallow`, camelCase string enums): + +```json +{ + "version": 1, + "process": { + "content": { "datDirectory": "...", "preparedAssetPath": "..." } + }, + "sessions": [{ + "id": "sess-1", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "testaccount", + "mode": "probe", + "character": { "id": 1342177290 }, + "policy": { "id": "idle" }, + "credential": { "provider": "standardInput", "reference": "session" }, + "plugins": ["ExamplePlugin"], + "loginCommands": ["/vt start"], + "loginCommandDelayMs": 500, + "statusFile": ".../launcher/sessions/sess-1/status.jsonl" + }] +} +``` + +Field rules: +- `process.paths` is OMITTED unless a caller genuinely supplies overrides + (never an empty object — the App reader has no `paths` member and + strict parsing rejects unknown keys; LA3 review finding 1). +- `mode`: ABSENT for normal play sessions; `"probe"` for the LA2 probe + (connect → characterList → graceful disconnect, no EnterWorld). The + headless loader accepts the field starting at LA2. +- `character`: exactly ONE of index|id|name; OMITTED entirely (not null) + for guiSelect and for probe sessions. +- `policy`: `{ "id": "idle" }` for headless play sessions ONLY; omitted + for gui/guiSelect/probe. +- `credential`: always `{ "provider": "standardInput", "reference": + "session" }` for launcher-composed configs. +- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional, + omitted-when-unset (never null, never `[]` for empty). Absent + `loginCommandDelayMs` means 500. + +Status stream (`statusFile`, one JSON object per line, writer flushes per +line, writer opens `FileShare.Read`, tailer opens +`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`, +`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`, +`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`, +`pluginFailed{plugin,error}`, `disconnected{reason}`, +`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"` +(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH +sides. Unknown `e` values must parse to a typed Unknown event, never +throw; a known `e` with a wrong payload shape should be distinguishable +from an unknown `e` (LA3 review finding 12). + Three pieces, one slice, because they share the session-config/status seam: 1. **App `--session-config `:** parsed once in `Program.cs` into @@ -416,7 +479,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields | | LA2 | — | | | | -| LA3 | implemented (`37d74e44`, campaign-la3); Opus review in flight | `37d74e44` | review in flight | 71/71 Windows AND 71/71 WSL (0600 test real on Linux); reviewer checking contract token-fidelity + tailer fix | +| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | | LA4 | — | | | | | LA5 | — | | | | | LA6 | — | | | | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 769203ef..cacc23d5 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies( CombatAttackOperationsSlot CombatAttackOperations, CombatFeedbackSlot CombatFeedback, TransferableResourceSlot PortalTunnelFallback, - Action Log) + Action Log, + /// Campaign LA slice LA1: the shared per-session status-event + /// writer, no-op when was + /// not configured. + SessionStatusWriter StatusWriter) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -1124,7 +1128,9 @@ internal sealed class SessionPlayerCompositionPhase acceptedPositionDrive, remotePlacementDrive), liveSessionCommands, - d.Log); + d.Log, + d.StatusWriter, + d.Options.SessionId ?? "app"); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( liveSession, new LiveSessionConnectOptions( @@ -1132,7 +1138,8 @@ internal sealed class SessionPlayerCompositionPhase d.Options.LiveHost, d.Options.LivePort, d.Options.LiveUser ?? string.Empty, - d.Options.LivePass ?? string.Empty)); + d.Options.LivePass ?? string.Empty, + d.Options.LiveCharacterSelector)); Fault(SessionPlayerCompositionPoint.SessionHostCreated); // The ImGui developer-tools debug toast sink was removed at Campaign V diff --git a/src/AcDream.App/Composition/SessionStartComposition.cs b/src/AcDream.App/Composition/SessionStartComposition.cs index fa352db9..35b48db9 100644 --- a/src/AcDream.App/Composition/SessionStartComposition.cs +++ b/src/AcDream.App/Composition/SessionStartComposition.cs @@ -1,9 +1,14 @@ using AcDream.Runtime; +using AcDream.Runtime.Session; namespace AcDream.App.Composition; internal sealed record SessionStartDependencies( - Action Log); + Action Log, + /// Campaign LA slice LA1: no-op when no statusFile was + /// configured. + SessionStatusWriter StatusWriter, + string SessionId); /// /// Terminal startup phase. Every callback, command target, and frame root is @@ -21,6 +26,9 @@ internal sealed class SessionStartCompositionPhase public void Start(FrameRootResult frame) { ArgumentNullException.ThrowIfNull(frame); + // Campaign LA slice LA1: "started" = session host start — the + // earliest point the graphical host actually attempts to connect. + _dependencies.StatusWriter.Started(_dependencies.SessionId); RuntimeSessionStartResult result = frame.GameRuntime.Session.Start(frame.GameRuntime.Generation); Report(result, _dependencies.Log); diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs new file mode 100644 index 00000000..313f75ea --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -0,0 +1,142 @@ +using System.Text.Json.Serialization; + +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1: the graphical host's reader for the pinned +/// session-config document shape shared with +/// AcDream.Headless.Configuration.HeadlessConfiguration — see +/// docs/plans/2026-08-14-launcher-campaign.md LA1 and +/// docs/superpowers/specs/2026-08-14-launcher-campaign-design.md §6. +/// +/// +/// This is a DELIBERATELY independent DTO set, not a shared type reused from +/// AcDream.Headless — Headless's config types are internal, tied to +/// its own OP7 characterOptions allow-list semantics, and Headless is +/// not a project App references. The two readers are cross-checked instead +/// by a shared fixture document both test suites parse +/// (SessionConfigurationSharedFixtureTests / +/// HeadlessConfigurationSharedFixtureTests). +/// +/// +/// +/// Differences from the Headless reader, all intentional per the pinned +/// contract: is OPTIONAL here +/// (absent = today's first-available fallback; the character-select screen +/// is LA7, not this slice); is parsed +/// but never consulted (App has no bot-policy concept); exactly ONE session +/// is required, not "one or more". +/// +/// +internal sealed class SessionConfiguration +{ + [JsonRequired] + public int Version { get; init; } + + public SessionProcessSettings? Process { get; init; } + + [JsonRequired] + public List Sessions { get; init; } = []; +} + +internal sealed class SessionProcessSettings +{ + public SessionContentDescriptor? Content { get; init; } +} + +internal sealed class SessionContentDescriptor +{ + [JsonRequired] + public string DatDirectory { get; init; } = string.Empty; + + [JsonRequired] + public string PreparedAssetPath { get; init; } = string.Empty; +} + +internal sealed record SessionDescriptor +{ + [JsonRequired] + public string Id { get; init; } = string.Empty; + + [JsonRequired] + public SessionEndpointDescriptor Endpoint { get; init; } = new(); + + [JsonRequired] + public string Account { get; init; } = string.Empty; + + /// Optional for the graphical host: absent means today's + /// existing first-available fallback stays in effect. The retail + /// character-select screen (LA7) is what actually consumes "no + /// selector" as "stop and let the user pick". + public SessionCharacterSelectorDescriptor? Character { get; init; } + + /// Accepted so the SAME document also satisfies the Headless + /// loader's JsonRequired policy field — parsed and ignored here; + /// App has no bot-policy concept. + public SessionPolicyDescriptor? Policy { get; init; } + + [JsonRequired] + public SessionCredentialDescriptor Credential { get; init; } = new(); + + /// Accepted-but-ignored by App; Headless's own loader owns the + /// allow-list semantics for this field (OP7 D8). + public Dictionary? CharacterOptions { get; init; } + + /// LA1: plugin ids to load. Absent = load all (LA5 consumes + /// this; parsed and carried here now per the pinned launch contract). + public List? Plugins { get; init; } + + /// LA1: ordered chat-typed strings run after entering world + /// (LA6 consumes this; parsed and carried here now). + public List? LoginCommands { get; init; } + + /// LA1: inter-command delay for , + /// milliseconds. Matches the pinned contract default of 500 ms. + public int LoginCommandDelayMs { get; init; } = 500; + + /// LA1: absolute path for the status-event JSONL stream. + /// Absent = no writer constructed. + public string? StatusFile { get; init; } +} + +internal sealed class SessionEndpointDescriptor +{ + [JsonRequired] + public string Host { get; init; } = string.Empty; + + [JsonRequired] + public int Port { get; init; } +} + +internal sealed class SessionCharacterSelectorDescriptor +{ + public int? Index { get; init; } + public uint? Id { get; init; } + public string? Name { get; init; } +} + +/// Loose by design: App never inspects the policy's shape beyond +/// "does this document parse" — Id/Role stay untyped strings so +/// this DTO never has to track Headless's own policy-id/role vocabulary. +internal sealed class SessionPolicyDescriptor +{ + public string? Id { get; init; } + public string? Role { get; init; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum SessionCredentialProviderKind +{ + Environment, + StandardInput, + File, +} + +internal sealed class SessionCredentialDescriptor +{ + [JsonRequired] + public SessionCredentialProviderKind Provider { get; init; } + + [JsonRequired] + public string Reference { get; init; } = string.Empty; +} diff --git a/src/AcDream.App/Configuration/SessionConfigurationException.cs b/src/AcDream.App/Configuration/SessionConfigurationException.cs new file mode 100644 index 00000000..05ca315f --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigurationException.cs @@ -0,0 +1,18 @@ +namespace AcDream.App.Configuration; + +/// Mirrors AcDream.Headless.Configuration.HeadlessConfigurationException +/// — a semantic validation failure of an already well-typed session-config +/// document (a type-SHAPE violation fails earlier, as a raw +/// during deserialization). +internal sealed class SessionConfigurationException : Exception +{ + internal SessionConfigurationException(string message) + : base(message) + { + } + + internal SessionConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs new file mode 100644 index 00000000..e26aa806 --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1: loads and validates the --session-config +/// document for the graphical host. Same strictness as +/// AcDream.Headless.Configuration.HeadlessConfigurationLoader +/// (camelCase, , camelCase +/// string enums) — see that type's own doc for why the two readers are +/// independent DTOs rather than a shared type. +/// +internal static class SessionConfigurationLoader +{ + private const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions Options = new() + { + AllowTrailingCommas = false, + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Disallow, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, + }; + + /// Loads the document and returns the exact one configured + /// the graphical host runs — the + /// document itself may only ever declare exactly one session. + internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load( + string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + using FileStream stream = File.OpenRead(fullPath); + SessionConfiguration? configuration = + JsonSerializer.Deserialize(stream, Options); + + if (configuration is null) + { + throw new SessionConfigurationException( + "The configuration document is empty."); + } + + if (configuration.Version != CurrentVersion) + { + throw new SessionConfigurationException( + $"Unsupported configuration version {configuration.Version}; " + + $"expected {CurrentVersion}."); + } + + if (configuration.Sessions is null + || configuration.Sessions.Count != 1) + { + throw new SessionConfigurationException( + "The graphical host requires exactly one configured session."); + } + + SessionDescriptor session = configuration.Sessions[0] + ?? throw new SessionConfigurationException( + "The configured session cannot be null."); + + ValidateContent(configuration.Process?.Content); + ValidateSession(session); + + return (configuration, session); + } + + private static void ValidateContent(SessionContentDescriptor? content) + { + if (content is null) + return; + if (string.IsNullOrWhiteSpace(content.DatDirectory) + || string.IsNullOrWhiteSpace(content.PreparedAssetPath)) + { + throw new SessionConfigurationException( + "process.content requires non-empty datDirectory and preparedAssetPath."); + } + } + + private static void ValidateSession(SessionDescriptor session) + { + if (string.IsNullOrWhiteSpace(session.Id)) + { + throw new SessionConfigurationException( + "The session requires a non-empty id."); + } + + if (session.Endpoint is null + || string.IsNullOrWhiteSpace(session.Endpoint.Host) + || session.Endpoint.Port is < 1 or > 65535) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' requires a host and a port from 1 through 65535."); + } + + if (string.IsNullOrWhiteSpace(session.Account)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' requires a non-empty account."); + } + + if (session.Character is { } selector) + { + int selectorCount = + (selector.Index.HasValue ? 1 : 0) + + (selector.Id.HasValue ? 1 : 0) + + (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0); + if (selectorCount != 1 + || selector.Index is < 0 + || selector.Id == 0u) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' character selector must specify " + + "exactly one valid index, id, or name."); + } + } + + if (session.Credential is null + || string.IsNullOrWhiteSpace(session.Credential.Reference)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' requires a credential reference."); + } + + if (session.Plugins is { } plugins) + { + foreach (string? plugin in plugins) + { + if (string.IsNullOrWhiteSpace(plugin)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' plugins entries must be non-empty strings."); + } + } + } + + if (session.LoginCommandDelayMs < 0) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' loginCommandDelayMs must be non-negative."); + } + + if (session.StatusFile is not null + && string.IsNullOrWhiteSpace(session.StatusFile)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' statusFile must be a non-empty path when present."); + } + } +} diff --git a/src/AcDream.App/Credentials/AppCredentialResolver.cs b/src/AcDream.App/Credentials/AppCredentialResolver.cs new file mode 100644 index 00000000..7d52fdab --- /dev/null +++ b/src/AcDream.App/Credentials/AppCredentialResolver.cs @@ -0,0 +1,155 @@ +using AcDream.App.Configuration; +using AcDream.App.Platform; + +namespace AcDream.App.Credentials; + +/// +/// Campaign LA slice LA1: resolves a --session-config session's +/// credential reference — the App-side mirror of +/// AcDream.Headless.Credentials.HeadlessCredentialResolver (see that +/// type's own file for why this is an independent port rather than a shared +/// reference). Supports the same three providers with the same semantics: +/// environment (read an env var), standardInput (read one line +/// from stdin, mirroring HeadlessCredentialResolver.ResolveStandardInput), +/// and file (read a credential file relative to a base directory, +/// rejecting symlinks and, on Linux, group/other-readable permissions). +/// +internal sealed class AppCredentialResolver +{ + private const UnixFileMode NonUserPermissionMask = + UnixFileMode.GroupRead + | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherWrite + | UnixFileMode.OtherExecute; + + private readonly TextReader _standardInput; + private readonly string _credentialBaseDirectory; + private readonly bool _isLinux; + + /// + /// is caller-supplied, never detected in this + /// file — LinuxPlatformBoundaryTests's platform-owner guard + /// requires every OS-family check to live under Platform/; + /// callers pass GraphicalHostPlatformServices's already-detected + /// value instead of this file re-detecting it itself. + /// + internal AppCredentialResolver( + TextReader standardInput, + string credentialBaseDirectory, + bool isLinux) + { + _standardInput = standardInput + ?? throw new ArgumentNullException(nameof(standardInput)); + ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory); + _credentialBaseDirectory = Path.GetFullPath(credentialBaseDirectory); + _isLinux = isLinux; + } + + internal AppCredentialSecret Resolve( + string sessionId, + SessionCredentialDescriptor credential) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(credential); + + string value; + try + { + value = credential.Provider switch + { + SessionCredentialProviderKind.Environment => + ResolveEnvironment(credential.Reference), + SessionCredentialProviderKind.StandardInput => + ResolveStandardInput(credential.Reference), + SessionCredentialProviderKind.File => + ResolveFile(credential.Reference), + _ => throw new AppCredentialException( + $"Session '{sessionId}' uses an unsupported credential provider."), + }; + } + catch (AppCredentialException) + { + throw; + } + catch (Exception error) + when (error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + throw new AppCredentialException( + $"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.", + error); + } + + try + { + return new AppCredentialSecret(credential.Reference, value.AsSpan()); + } + finally + { + // The BCL returns immutable strings from environment, TextReader, + // and File APIs. Do not retain another copy in the resolver; the + // erasable char[] owner becomes the sole explicit retained copy. + value = string.Empty; + } + } + + private static string ResolveEnvironment(string reference) + { + string? value = Environment.GetEnvironmentVariable(reference); + if (string.IsNullOrEmpty(value)) + { + throw new AppCredentialException( + $"Credential environment reference '{reference}' is unavailable."); + } + return value; + } + + private string ResolveStandardInput(string reference) + { + string? value = _standardInput.ReadLine(); + if (string.IsNullOrEmpty(value)) + { + throw new AppCredentialException( + $"Credential standard-input reference '{reference}' is unavailable."); + } + return value; + } + + private string ResolveFile(string reference) + { + string path = Path.GetFullPath(reference, _credentialBaseDirectory); + var file = new FileInfo(path); + if (file.LinkTarget is not null) + { + throw new AppCredentialException( + $"Credential file reference '{reference}' cannot be a symbolic link."); + } + + // RuntimePlatformGuard.IsLinuxRuntime is the CA1416-recognized guard + // for File.GetUnixFileMode below; _isLinux is the separate, + // caller-injected value tests use for deterministic cross-platform + // coverage (see the constructor's own doc). + if (RuntimePlatformGuard.IsLinuxRuntime && _isLinux) + { + UnixFileMode mode = File.GetUnixFileMode(path); + if ((mode & NonUserPermissionMask) != 0 + || (mode & UnixFileMode.UserRead) == 0) + { + throw new AppCredentialException( + $"Credential file reference '{reference}' must be readable only by its owner."); + } + } + + string value = File.ReadAllText(path).TrimEnd('\r', '\n'); + if (value.Length == 0) + { + throw new AppCredentialException( + $"Credential file reference '{reference}' is empty."); + } + return value; + } +} diff --git a/src/AcDream.App/Credentials/AppCredentialSecret.cs b/src/AcDream.App/Credentials/AppCredentialSecret.cs new file mode 100644 index 00000000..a251f0ed --- /dev/null +++ b/src/AcDream.App/Credentials/AppCredentialSecret.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; + +namespace AcDream.App.Credentials; + +/// +/// Campaign LA slice LA1: retains a resolved --session-config +/// credential in erasable memory — the App-side mirror of +/// AcDream.Headless.Credentials.HeadlessCredentialSecret (that type is +/// internal to the Headless project, so this is a minimal, independent port +/// rather than a shared reference). The network boundary still requires one +/// short-lived immutable string; callers must not retain that value beyond +/// constructing the connect request. +/// +internal sealed class AppCredentialSecret : IDisposable +{ + private char[]? _buffer; + + internal AppCredentialSecret(string referenceId, ReadOnlySpan value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(referenceId); + if (value.IsEmpty) + { + throw new AppCredentialException( + $"Credential '{referenceId}' resolved to an empty secret."); + } + + ReferenceId = referenceId; + _buffer = value.ToArray(); + } + + internal string ReferenceId { get; } + internal bool IsDisposed => _buffer is null; + + internal string Reveal() + { + ObjectDisposedException.ThrowIf(_buffer is null, this); + return new string(_buffer); + } + + public void Dispose() + { + char[]? buffer = Interlocked.Exchange(ref _buffer, null); + if (buffer is null) + return; + CryptographicOperations.ZeroMemory( + System.Runtime.InteropServices.MemoryMarshal.AsBytes( + buffer.AsSpan())); + } + + public override string ToString() => + $"[redacted:{ReferenceId}]"; +} + +internal sealed class AppCredentialException : Exception +{ + internal AppCredentialException(string message) + : base(message) + { + } + + internal AppCredentialException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 174dfa3d..805d0170 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -104,6 +104,8 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionCommandSurface _commands; private readonly Action _log; private readonly LiveMovementStatsApplier _movementStats; + private readonly SessionStatusWriter _statusWriter; + private readonly string _sessionId; public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -112,7 +114,9 @@ internal sealed class LiveSessionRuntimeFactory LiveSessionInteractionRuntime interaction, LiveSessionWorldRuntime world, LiveSessionCommandSurface commands, - Action log) + Action log, + SessionStatusWriter? statusWriter = null, + string sessionId = "app") { _player = player ?? throw new ArgumentNullException(nameof(player)); _domain = domain ?? throw new ArgumentNullException(nameof(domain)); @@ -122,6 +126,10 @@ internal sealed class LiveSessionRuntimeFactory _world = world ?? throw new ArgumentNullException(nameof(world)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? throw new ArgumentNullException(nameof(log)); + // Campaign LA slice LA1: a no-op instance when the caller has no + // status file configured — every call site below stays unconditional. + _statusWriter = statusWriter ?? new SessionStatusWriter(null); + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); // C3c-F1: stat recomputes route through the Runtime movement owner's // typed application seam; App keeps zero direct controller mutations. _movementStats = new LiveMovementStatsApplier( @@ -176,9 +184,17 @@ internal sealed class LiveSessionRuntimeFactory $"connecting to {host}:{port} as {user}", chatType: 1), Connected: () => + { _domain.Communication.Chat.OnSystemMessage( "connected — character list received", - chatType: 1)), + chatType: 1); + _statusWriter.Connected(_sessionId); + }, + Roster: roster => _statusWriter.CharacterList(_sessionId, roster), + CharacterEntered: selection => _statusWriter.EnteredWorld( + _sessionId, + selection.CharacterId, + selection.CharacterName)), connectOptions); } diff --git a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs index 42d9d373..bd95460d 100644 --- a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs +++ b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using System.Runtime.Versioning; using AcDream.App.Rendering; using AcDream.Platform; @@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem Linux, } +/// +/// Campaign LA slice LA1: a [SupportedOSPlatformGuard]-annotated +/// runtime-OS check, for code OUTSIDE Platform/ that needs a +/// CA1416-recognized guard around a Linux-only API (e.g. +/// AppCredentialResolver's File.GetUnixFileMode call) without +/// re-detecting the OS itself — LinuxPlatformBoundaryTests +/// .OperatingSystemChecksRemainInsidePlatformOwners requires every such +/// check to live under this folder. +/// +internal static class RuntimePlatformGuard +{ + [SupportedOSPlatformGuard("linux")] + internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux(); +} + internal sealed record GraphicalNativeDependency( string Feature, string PublishedFileName); diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 3d46ce90..61ed733a 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -1,4 +1,6 @@ using AcDream.App; +using AcDream.App.Configuration; +using AcDream.App.Credentials; using AcDream.App.Plugins; using AcDream.App.Platform; using AcDream.App.Rendering; @@ -32,17 +34,96 @@ Log.Information( dependency => $"{dependency.Feature}={dependency.PublishedFileName}"))); -var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); -if (string.IsNullOrWhiteSpace(datDir)) -{ - Log.Error("usage: AcDream.App (or set ACDREAM_DAT_DIR)"); - return 2; -} +// Campaign LA slice LA1: --session-config is purely additive — the +// existing one positional dat-dir argument and every ACDREAM_* env var keep +// working exactly as before when the flag is absent. See +// docs/plans/2026-08-14-launcher-campaign.md LA1. +string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config"); +string[] positionalArgs = WithoutFlagAndValue(args, "--session-config"); + +var datDirArg = positionalArgs.FirstOrDefault(); +var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); // Single read of the startup-time process environment. Every downstream // consumer (GameWindow + collaborators) reads the typed bundle, not the // raw env vars. See docs/architecture/code-structure.md §2 Rule 4. -var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); +RuntimeOptions runtimeOptions; +if (sessionConfigFlagPath is not null) +{ + SessionConfiguration sessionConfig; + SessionDescriptor session; + try + { + (sessionConfig, session) = SessionConfigurationLoader.Load(sessionConfigFlagPath); + } + catch (Exception error) + when (error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Text.Json.JsonException + or SessionConfigurationException) + { + Log.Error("--session-config invalid: {Error}", error.Message); + return 2; + } + + string? resolvedDatDir = + NullIfEmpty(sessionConfig.Process?.Content?.DatDirectory) + ?? NullIfEmpty(datDirArg) + ?? NullIfEmpty(envDatDir); + if (resolvedDatDir is null) + { + Log.Error( + "usage: AcDream.App (or set ACDREAM_DAT_DIR, " + + "or supply process.content.datDirectory in --session-config)"); + return 2; + } + + AppCredentialSecret? secret = null; + try + { + var resolver = new AppCredentialResolver( + Console.In, + applicationPaths.ConfigDirectory, + graphicalPlatform.OperatingSystem + == GraphicalHostOperatingSystem.Linux); + secret = resolver.Resolve(session.Id, session.Credential); + runtimeOptions = RuntimeOptions.FromSessionConfig( + resolvedDatDir, + Environment.GetEnvironmentVariable, + sessionConfigFlagPath, + sessionConfig, + session, + secret.Reveal()); + } + catch (AppCredentialException error) + { + Log.Error("--session-config credential unavailable: {Error}", error.Message); + return 2; + } + finally + { + secret?.Dispose(); + } + + // Env-var flow untouched when the flag is absent; when both are present + // the flag wins — this line makes that explicit rather than silent. + Log.Information( + "--session-config {Path} present; overriding ACDREAM_LIVE*/ACDREAM_TEST_* " + + "env-var live-session settings", + sessionConfigFlagPath); +} +else +{ + var datDir = datDirArg ?? envDatDir; + if (string.IsNullOrWhiteSpace(datDir)) + { + Log.Error("usage: AcDream.App (or set ACDREAM_DAT_DIR)"); + return 2; + } + runtimeOptions = RuntimeOptions.FromEnvironment(datDir); +} if (runtimeOptions.DevTools) { @@ -158,3 +239,35 @@ finally } return 0; + +// Campaign LA slice LA1: --session-config parsing helpers. Kept +// local/minimal rather than a general-purpose CLI parser — App has exactly +// one optional flag-with-value today; the positional dat-dir argument must +// stay untouched by its presence (see the comment above the flag parse). +static string? ExtractFlagValue(string[] arguments, string flag) +{ + for (int i = 0; i < arguments.Length - 1; i++) + { + if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) + return arguments[i + 1]; + } + return null; +} + +static string[] WithoutFlagAndValue(string[] arguments, string flag) +{ + var result = new List(arguments.Length); + for (int i = 0; i < arguments.Length; i++) + { + if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) + { + i++; // also skip the flag's value + continue; + } + result.Add(arguments[i]); + } + return [.. result]; +} + +static string? NullIfEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 74a61be4..19cd8c44 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -36,6 +36,9 @@ public sealed class GameWindow : / (double)System.Diagnostics.Stopwatch.Frequency; private readonly AcDream.App.RuntimeOptions _options; + // Campaign LA slice LA1: no-op instance when --session-config didn't + // configure a statusFile (or the env-var launch path was used at all). + private readonly SessionStatusWriter _statusWriter; private readonly AnimationPresentationDiagnostics _animationDiagnostics; private readonly string _datDir; private readonly WorldGameState _worldGameState; @@ -615,6 +618,7 @@ public sealed class GameWindow : GraphicalHostPlatformServices platformServices) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + _statusWriter = new SessionStatusWriter(options.StatusFilePath); _platformServices = platformServices ?? throw new ArgumentNullException(nameof(platformServices)); _applicationPaths = _platformServices.Paths; @@ -1489,7 +1493,8 @@ public sealed class GameWindow : _combatAttackOperations, _combatFeedback, _portalTunnelFallback, - Console.WriteLine), + Console.WriteLine, + _statusWriter), this).Compose( hostInputCamera, contentEffectsAudio, @@ -1548,7 +1553,10 @@ public sealed class GameWindow : livePresentation, sessionPlayer), frameRoots => new SessionStartCompositionPhase( - new SessionStartDependencies(Console.WriteLine)) + new SessionStartDependencies( + Console.WriteLine, + _statusWriter, + _options.SessionId ?? "app")) .Start(frameRoots)); } @@ -1636,13 +1644,30 @@ public sealed class GameWindow : private void CompleteShutdown(bool releaseNativeWindow) { if (!_lifetime.HasShutdownRoots) + { + // Campaign LA slice LA1: capture BEFORE the shutdown roots run — + // by the time teardown completes, IsInWorld is always false + // regardless of whether a real session was ever connected. + // OnClosing() and Dispose() both funnel through this method; + // HasShutdownRoots's own guard means this fires exactly once, + // from whichever of the two reaches it first. + if (_runtime.Session.IsInWorld) + _statusWriter.Disconnected(_options.SessionId ?? "app", "stopped"); _lifetime.PublishShutdownRoots(CaptureShutdownRoots()); + } GameWindowLifetimeReport report = releaseNativeWindow ? _lifetime.CompleteAndReleaseNativeWindow() : _lifetime.TryComplete(); if (report.Status == GameWindowLifetimeStatus.Complete) + { + // "exited" = terminal — only the true Dispose() call (not the + // OnClosing() native-window-close-request pass) represents the + // process actually being done. + if (releaseNativeWindow) + _statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed"); return; + } Console.Error.WriteLine( $"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}"); @@ -1655,6 +1680,14 @@ public sealed class GameWindow : if (report.Error is not null) Console.Error.WriteLine($"[shutdown] {report.Error}"); + + if (releaseNativeWindow) + { + _statusWriter.Exited( + _options.SessionId ?? "app", + 1, + "shutdown-incomplete"); + } } private GameWindowShutdownRoots CaptureShutdownRoots() => new( diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 703a14a3..7b3266d1 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; +using AcDream.App.Configuration; using AcDream.App.Rendering.Residency; using AcDream.App.Streaming; +using AcDream.Runtime.Session; namespace AcDream.App; @@ -62,7 +65,34 @@ public sealed record RuntimeOptions( string? VulkanDeviceOverride, string? VulkanForcedUnsupportedFeature, bool VulkanCapabilityProbe, - int VulkanCapabilityProbeFrames) + int VulkanCapabilityProbeFrames, + /// Campaign LA slice LA1: the raw --session-config path, + /// or when the flag was not supplied (the env-var + /// dev flow). Kept for diagnostics/logging only. + string? SessionConfigPath, + /// Campaign LA slice LA1: the configured session's id, used as + /// the sessionId field on every status-stream event. Defaults to + /// "app" at every call site when unset (env-var flow). + string? SessionId, + /// Campaign LA slice LA1: the session-config character + /// selector, or for today's existing + /// first-available fallback (absent selector = LA7's char-select screen + /// stop point once that slice lands; this slice does not build the + /// screen). + LiveSessionCharacterSelector? LiveCharacterSelector, + /// Campaign LA slice LA1: absolute path for the status-event + /// JSONL stream. = no writer constructed. + string? StatusFilePath, + /// Campaign LA slice LA1: plugin ids to load. + /// = load every discovered plugin (today's + /// behavior). Consumed by LA5; parsed and carried now. + IReadOnlyList? Plugins, + /// Campaign LA slice LA1: ordered chat-typed strings run once + /// entered-world. Consumed by LA6; parsed and carried now. + IReadOnlyList LoginCommands, + /// Campaign LA slice LA1: inter-command delay for + /// , milliseconds. + int LoginCommandDelayMs) { /// /// Build options from the process environment. Used by @@ -170,9 +200,72 @@ public sealed record RuntimeOptions( // closes the window. Zero -- unset, unparseable, or an explicit 0 -- // keeps the interactive behaviour, so no existing invocation changes. VulkanCapabilityProbeFrames: - TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0); + TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0, + // Campaign LA slice LA1: the env-var dev flow never carries a + // session-config document — every new field below stays at its + // "nothing configured" default. RuntimeOptions.FromSessionConfig + // overlays the real values on top of this base. + SessionConfigPath: null, + SessionId: null, + LiveCharacterSelector: null, + StatusFilePath: null, + Plugins: null, + LoginCommands: [], + LoginCommandDelayMs: 500); } + /// + /// Campaign LA slice LA1: builds options for the --session-config + /// launch path. Starts from the same env-var parse as + /// (diagnostic/dev flags are still + /// env-controlled — only the LIVE session settings and the five new LA1 + /// fields come from the document) and overlays the resolved session. + /// is revealed into + /// exactly as wide as the existing env-var flow — + /// see that field's own doc. + /// + internal static RuntimeOptions FromSessionConfig( + string datDir, + Func env, + string sessionConfigPath, + SessionConfiguration config, + SessionDescriptor session, + string? resolvedPassword) + { + if (config is null) throw new ArgumentNullException(nameof(config)); + if (session is null) throw new ArgumentNullException(nameof(session)); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionConfigPath); + + RuntimeOptions baseOptions = Parse(datDir, env); + SessionContentDescriptor? content = config.Process?.Content; + return baseOptions with + { + PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath) + ?? baseOptions.PreparedAssetPath, + LiveMode = true, + LiveHost = session.Endpoint.Host, + LivePort = session.Endpoint.Port, + LiveUser = session.Account, + LivePass = resolvedPassword, + SessionConfigPath = sessionConfigPath, + SessionId = session.Id, + LiveCharacterSelector = MapCharacterSelector(session.Character), + StatusFilePath = NullIfEmpty(session.StatusFile), + Plugins = session.Plugins, + LoginCommands = (IReadOnlyList?)session.LoginCommands ?? [], + LoginCommandDelayMs = session.LoginCommandDelayMs, + }; + } + + private static LiveSessionCharacterSelector? MapCharacterSelector( + SessionCharacterSelectorDescriptor? selector) => + selector is null + ? null + : new LiveSessionCharacterSelector( + selector.Index, + selector.Id, + selector.Name); + /// True iff live-mode credentials are present and valid for connecting. public bool HasLiveCredentials => LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass); diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 37e62a4d..42fe7e67 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor /// legal no-ops. /// public Dictionary? CharacterOptions { get; init; } + + /// + /// Campaign LA slice LA1: plugin ids to load from the standard plugins + /// directory (docs/plans/2026-08-14-launcher-campaign.md LA1). + /// Absent means load every discovered plugin (today's dev behavior); + /// LA5 wires this into an actual allow-list filter. Parsed and carried + /// here now so the session-config shape is stable before LA5 lands. + /// + public List? Plugins { get; init; } + + /// + /// Campaign LA slice LA1: ordered chat-typed strings run once the + /// session enters world. LA6 wires actual execution; parsed and carried + /// here now. + /// + public List? LoginCommands { get; init; } + + /// + /// Campaign LA slice LA1: inter-command delay for + /// , in milliseconds. Matches the pinned + /// launch-contract default (500 ms) when the field is absent from the + /// document. + /// + public int LoginCommandDelayMs { get; init; } = 500; + + /// + /// Campaign LA slice LA1: absolute path for this session's status-event + /// JSONL stream (docs/superpowers/specs/2026-08-14-launcher-campaign-design.md + /// §6). Absent means no + /// is constructed for this session. + /// + public string? StatusFile { get; init; } } internal sealed class HeadlessEndpointDescriptor diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 101850c8..016b79c2 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader } ValidateCharacterOptions(session); + ValidateLaunchContractFields(session); + } + + /// + /// Campaign LA slice LA1: validates the four new optional per-session + /// fields shared with the App session-config reader (see + /// docs/plans/2026-08-14-launcher-campaign.md LA1's pinned + /// contract). All four stay optional; only their SHAPE is checked here + /// — parsing/executing plugins/loginCommands is LA5/LA6. + /// + private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session) + { + if (session.Plugins is { } plugins) + { + foreach (string? plugin in plugins) + { + if (string.IsNullOrWhiteSpace(plugin)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' plugins entries must be non-empty strings."); + } + } + } + + if (session.LoginCommandDelayMs < 0) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' loginCommandDelayMs must be non-negative."); + } + + if (session.StatusFile is not null + && string.IsNullOrWhiteSpace(session.StatusFile)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' statusFile must be a non-empty path when present."); + } } /// diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 5682e156..ea3a56d4 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly HeadlessCredentialSecret _credential; private readonly HeadlessDiagnosticWriter _diagnostics; /// + /// Campaign LA slice LA1: a SEPARATE per-session sink from + /// — a no-op instance when + /// was not configured. + /// See 's own doc for why this is not a + /// rework of the shared-stdout diagnostics writer. + /// + private readonly SessionStatusWriter _statusWriter; + /// Guards 's disconnected status event + /// so a Stop() on a session that never actually reached Connected (e.g. + /// disposing a fresh, never-started host) does not report a spurious + /// disconnect. + private bool _hasConnected; + /// /// Campaign OP slice OP7 (2026-08-11), D8: the parsed /// characterOptions block — empty when the config omitted it. /// Parsed once at construction; @@ -271,6 +284,10 @@ internal sealed class HeadlessSessionHost : IDisposable var commands = new DirectGameRuntimeCommandAdapter( runtime, bridge); + // Campaign LA slice LA1: no-op instance when + // descriptor.StatusFile is unset — every call site below stays + // unconditional. + var statusWriter = new SessionStatusWriter(descriptor.StatusFile); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( @@ -318,14 +335,25 @@ internal sealed class HeadlessSessionHost : IDisposable descriptor.Id, $"connecting:{host}:{port}:{user}", runtime.Generation.Value), - () => diagnostics.Message( + () => + { + diagnostics.Message( + descriptor.Id, + "connected", + runtime.Generation.Value); + statusWriter.Connected(descriptor.Id); + _hasConnected = true; + }, + roster => statusWriter.CharacterList(descriptor.Id, roster), + selection => statusWriter.EnteredWorld( descriptor.Id, - "connected", - runtime.Generation.Value))); + selection.CharacterId, + selection.CharacterName))); Runtime = runtime; Commands = commands; _liveSession = liveSession; + _statusWriter = statusWriter; _localPlayerFrame = runtime.CreateLocalPlayerFrameController( new HeadlessLocalPlayerFrameHost( @@ -421,8 +449,13 @@ internal sealed class HeadlessSessionHost : IDisposable _pendingConfirmation = null; } - internal RuntimeSessionStartResult Start() => - Commands.Session.Start(Runtime.Generation); + internal RuntimeSessionStartResult Start() + { + // Campaign LA slice LA1: "started" = session host start — the + // earliest point this session actually attempts to connect. + _statusWriter.Started(_descriptor.Id); + return Commands.Session.Start(Runtime.Generation); + } internal RuntimeSessionStartResult Reconnect() => Commands.Session.Reconnect(Runtime.Generation); @@ -470,6 +503,15 @@ internal sealed class HeadlessSessionHost : IDisposable // (possibly disposed) WorldSession in the window between this Stop // and the next CreateEventRoute call. _currentSession = null; + // Campaign LA slice LA1: only report a disconnect for a session that + // actually reached Connected — a Stop() on a never-started or + // never-connected host (e.g. immediate Dispose()) is not a real + // disconnect. + if (_hasConnected) + { + _hasConnected = false; + _statusWriter.Disconnected(_descriptor.Id, "stopped"); + } return result; } @@ -592,6 +634,13 @@ internal sealed class HeadlessSessionHost : IDisposable _descriptor.Id, "disposed", _stoppedGeneration); + // Campaign LA slice LA1: "exited" = terminal — the sole + // point every disposal path (graceful and post- + // quarantine) converges on. + _statusWriter.Exited( + _descriptor.Id, + _faulted ? 1 : 0, + _faulted ? "fault" : "disposed"); _disposeStage++; _disposed = true; break; diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index d09f0ad9..1ecd5d00 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -50,6 +50,32 @@ public sealed record LiveSessionStartResult( LiveSessionCharacterSelection? Selection = null, Exception? Error = null); +/// +/// Campaign LA slice LA1: one roster entry as reported by +/// — decoupled from the wire type so the +/// lifecycle-host seam does not leak AcDream.Core.Net.Messages shapes +/// into every consumer. +/// +public readonly record struct LiveSessionRosterEntry( + uint Id, + string Name, + uint SecondsGreyedOut); + +/// +/// Campaign LA slice LA1: the account's active-character roster, reported to +/// right after +/// CharacterList arrives and BEFORE selection — see +/// docs/plans/2026-08-14-launcher-campaign.md LA1 item 2. Hosts forward +/// this to their status stream (characterList event) and, later +/// (LA7/LA8), to the character-select screen. Deleted characters are +/// deliberately excluded — the same candidate set +/// already uses. +/// +public sealed record LiveSessionRosterReport( + string AccountName, + int SlotCount, + IReadOnlyList Entries); + /// /// Runtime boundary for the domain and presentation sinks attached to one /// exact generation. The controller owns the @@ -61,6 +87,10 @@ public interface ILiveSessionLifecycleHost void ResetSessionState(RuntimeGenerationToken retiringGeneration); void ReportConnecting(string host, int port, string user); void ReportConnected(); + /// Campaign LA slice LA1: reported once per successful + /// CharacterList receipt, right before character selection. See + /// . + void ReportRoster(LiveSessionRosterReport roster); void ApplySelectedCharacter(LiveSessionCharacterSelection selection); void ApplyEnteredWorld(LiveSessionCharacterSelection selection); void DetachSession(WorldSession session); @@ -610,6 +640,13 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); CharacterList.Parsed? characters = _operations.GetCharacters(session); + if (characters is not null) + { + host.ReportRoster(BuildRosterReport(characters)); + if (!IsCurrent(scope, generation)) + return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); + } + if (characters is null || !TrySelectCharacter( characters, @@ -838,6 +875,29 @@ public sealed class LiveSessionController private LiveSessionStartResult ConnectedResult() => new(LiveSessionStartStatus.Connected, _activeSelection); + /// Campaign LA slice LA1: projects the wire-shaped + /// into the decoupled + /// . Deleted characters are + /// excluded, matching 's + /// candidate set. + private static LiveSessionRosterReport BuildRosterReport( + CharacterList.Parsed characters) + { + var entries = new LiveSessionRosterEntry[characters.Characters.Count]; + for (int i = 0; i < entries.Length; i++) + { + CharacterList.Character character = characters.Characters[i]; + entries[i] = new LiveSessionRosterEntry( + character.Id, + character.Name, + character.SecondsGreyedOut); + } + return new LiveSessionRosterReport( + characters.AccountName, + characters.SlotCount, + entries); + } + private static bool TrySelectCharacter( CharacterList.Parsed characters, LiveSessionCharacterSelector? selector, diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 049b8286..95e8f553 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -28,7 +28,18 @@ public sealed record LiveSessionHostBindings( LiveSessionSelectionBindings Selection, LiveSessionEnteredWorldBindings EnteredWorld, Action Connecting, - Action Connected); + Action Connected, + /// Campaign LA slice LA1: reported once per successful + /// CharacterList receipt, right before character selection — see + /// . Hosts forward this to their + /// status stream's characterList event. + Action Roster, + /// Campaign LA slice LA1: reported once entered-world state is + /// applied, carrying the full selection (id + name) — unlike + /// 's narrow SetActiveCharacter(string) + /// fan-out, this exists so a status writer can emit the + /// enteredWorld event's characterId field. + Action CharacterEntered); /// /// Runtime host for the one canonical . @@ -84,6 +95,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands private readonly LiveSessionRoutingFactories _routing; private readonly LiveSessionSelectionBindings _selection; private readonly LiveSessionEnteredWorldBindings _enteredWorld; + private readonly Action _characterEntered; private readonly Action _reset; private readonly LiveSessionLifecycleHost _lifecycle; private PendingRouteRollback? _pendingRouteRollback; @@ -100,11 +112,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands _selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection)); _enteredWorld = bindings.EnteredWorld ?? throw new ArgumentNullException(nameof(bindings.EnteredWorld)); + _characterEntered = bindings.CharacterEntered + ?? throw new ArgumentNullException(nameof(bindings.CharacterEntered)); ArgumentNullException.ThrowIfNull(_routing.CreateEvents); ArgumentNullException.ThrowIfNull(_routing.CreateCommands); ArgumentNullException.ThrowIfNull(bindings.Reset); ArgumentNullException.ThrowIfNull(bindings.Connecting); ArgumentNullException.ThrowIfNull(bindings.Connected); + ArgumentNullException.ThrowIfNull(bindings.Roster); Validate(_selection, _enteredWorld); _reset = bindings.Reset; @@ -113,6 +128,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands Reset: ResetSessionState, Connecting: bindings.Connecting, Connected: bindings.Connected, + Roster: bindings.Roster, Selected: ApplySelection, Entered: ApplyEnteredWorld)); } @@ -217,6 +233,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands _enteredWorld.SyncToolbar(); _enteredWorld.LoadCharacterSettings(name); _enteredWorld.ArmPlayerModeAutoEntry(); + _characterEntered(selection); } private void RethrowWithRetryableRollback( diff --git a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs index b0134366..7f1a7f92 100644 --- a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs @@ -7,6 +7,7 @@ public sealed record LiveSessionLifecycleBindings( Action Reset, Action Connecting, Action Connected, + Action Roster, Action Selected, Action Entered); @@ -27,6 +28,7 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost ArgumentNullException.ThrowIfNull(bindings.Reset); ArgumentNullException.ThrowIfNull(bindings.Connecting); ArgumentNullException.ThrowIfNull(bindings.Connected); + ArgumentNullException.ThrowIfNull(bindings.Roster); ArgumentNullException.ThrowIfNull(bindings.Selected); ArgumentNullException.ThrowIfNull(bindings.Entered); } @@ -51,6 +53,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost public void ReportConnected() => _bindings.Connected(); + public void ReportRoster(LiveSessionRosterReport roster) => + _bindings.Roster(roster); + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) => _bindings.Selected(selection); diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs new file mode 100644 index 00000000..b2260453 --- /dev/null +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -0,0 +1,162 @@ +using System.Text.Json; + +namespace AcDream.Runtime.Session; + +/// +/// Campaign LA slice LA1: appends one JSON object per line to a per-session +/// status-event file the launcher tails +/// (docs/plans/2026-08-14-launcher-campaign.md LA1, +/// docs/superpowers/specs/2026-08-14-launcher-campaign-design.md §6). +/// +/// +/// This is a SEPARATE sink from HeadlessDiagnosticWriter — that class +/// is a single shared-stdout JSONL diagnostics stream with no per-session +/// file; this class writes one file per session, meant to be read by an +/// external process (the launcher) rather than scraped from console output. +/// Event shapes are versioned ("v":1) so a future event kind +/// (pluginLoaded/pluginFailed, LA5) can be added without +/// breaking an existing reader. +/// +/// +/// +/// Every write opens the file in append mode with +/// so an external tailer can read the file concurrently, writes exactly one +/// line, flushes, and closes — there is no long-lived file handle to leak or +/// to dispose. A writer constructed with a or blank +/// path is a permanent no-op: every method becomes a cheap null-check, so +/// callers never need to guard construction sites on whether a status file +/// was configured. +/// +/// +/// +/// Never write credential material into this stream. Every +/// event method below takes only identifiers, names, and counts — there is no +/// parameter shape that could carry a password, by construction. +/// +/// +public sealed class SessionStatusWriter +{ + private const int VocabularyVersion = 1; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly string? _path; + private readonly TimeProvider _timeProvider; + private readonly object _gate = new(); + + public SessionStatusWriter(string? path, TimeProvider? timeProvider = null) + { + _path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// True when this writer has a configured path and will actually append + /// events. Lets a caller with an expensive report to build (e.g. the + /// roster projection) skip that work entirely when nobody configured a + /// status file for this session. + /// + public bool IsEnabled => _path is not null; + + public void Started(string sessionId) => + Write(new + { + v = VocabularyVersion, + e = "started", + t = Now(), + sessionId, + }); + + public void Connected(string sessionId) => + Write(new + { + v = VocabularyVersion, + e = "connected", + t = Now(), + sessionId, + }); + + public void CharacterList(string sessionId, LiveSessionRosterReport roster) + { + ArgumentNullException.ThrowIfNull(roster); + if (!IsEnabled) + return; + + Write(new + { + v = VocabularyVersion, + e = "characterList", + t = Now(), + sessionId, + accountName = roster.AccountName, + slotCount = roster.SlotCount, + characters = roster.Entries + .Select(static entry => new + { + id = entry.Id, + name = entry.Name, + secondsGreyedOut = entry.SecondsGreyedOut, + }) + .ToArray(), + }); + } + + public void EnteredWorld(string sessionId, uint characterId, string characterName) => + Write(new + { + v = VocabularyVersion, + e = "enteredWorld", + t = Now(), + sessionId, + characterId, + characterName, + }); + + public void Disconnected(string sessionId, string reason) => + Write(new + { + v = VocabularyVersion, + e = "disconnected", + t = Now(), + sessionId, + reason, + }); + + public void Exited(string sessionId, int code, string reason) => + Write(new + { + v = VocabularyVersion, + e = "exited", + t = Now(), + sessionId, + code, + reason, + }); + + private string Now() => + _timeProvider.GetUtcNow().ToString( + "O", + System.Globalization.CultureInfo.InvariantCulture); + + private void Write(T value) + { + if (_path is not { } path) + return; + + string line = JsonSerializer.Serialize(value, JsonOptions); + lock (_gate) + { + using FileStream stream = new( + path, + FileMode.Append, + FileAccess.Write, + FileShare.Read); + using var writer = new StreamWriter(stream); + writer.WriteLine(line); + writer.Flush(); + } + } +} diff --git a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs new file mode 100644 index 00000000..9cbe54e0 --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs @@ -0,0 +1,148 @@ +using AcDream.App; +using AcDream.App.Configuration; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1: round-trip tests for +/// — the overlay that turns a +/// parsed / +/// into the same typed bundle the env-var dev flow produces. +/// +public sealed class RuntimeOptionsSessionConfigTests +{ + [Fact] + public void SessionConfigOverridesLiveSettingsAndCarriesAllFiveNewFields() + { + var config = new SessionConfiguration { Version = 1 }; + var session = new SessionDescriptor + { + Id = "gui-session", + Endpoint = new SessionEndpointDescriptor + { + Host = "192.168.1.50", + Port = 9123, + }, + Account = "guiaccount", + Character = new SessionCharacterSelectorDescriptor { Name = "GuiToon" }, + Credential = new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.Environment, + Reference = "IGNORED", + }, + Plugins = ["PluginA", "PluginB"], + LoginCommands = ["/tell x, hi"], + LoginCommandDelayMs = 900, + StatusFile = "status.jsonl", + }; + + RuntimeOptions options = RuntimeOptions.FromSessionConfig( + "D:\\dat", + _ => null, + "session.json", + config, + session, + "resolved-password"); + + Assert.True(options.LiveMode); + Assert.Equal("192.168.1.50", options.LiveHost); + Assert.Equal(9123, options.LivePort); + Assert.Equal("guiaccount", options.LiveUser); + Assert.Equal("resolved-password", options.LivePass); + Assert.Equal("session.json", options.SessionConfigPath); + Assert.Equal("gui-session", options.SessionId); + Assert.Equal( + new LiveSessionCharacterSelector(null, null, "GuiToon"), + options.LiveCharacterSelector); + Assert.Equal("status.jsonl", options.StatusFilePath); + Assert.Equal(["PluginA", "PluginB"], options.Plugins); + Assert.Equal(["/tell x, hi"], options.LoginCommands); + Assert.Equal(900, options.LoginCommandDelayMs); + } + + [Fact] + public void AbsentCharacterSelectorLeavesFirstAvailableFallbackInEffect() + { + var config = new SessionConfiguration { Version = 1 }; + var session = new SessionDescriptor + { + Id = "no-selector", + Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 }, + Account = "account", + Credential = new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.Environment, + Reference = "X", + }, + }; + + RuntimeOptions options = RuntimeOptions.FromSessionConfig( + "D:\\dat", + _ => null, + "session.json", + config, + session, + "password"); + + Assert.Null(options.LiveCharacterSelector); + Assert.Null(options.Plugins); + Assert.Empty(options.LoginCommands); + Assert.Equal(500, options.LoginCommandDelayMs); + Assert.Null(options.StatusFilePath); + } + + [Fact] + public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath() + { + var config = new SessionConfiguration + { + Version = 1, + Process = new SessionProcessSettings + { + Content = new SessionContentDescriptor + { + DatDirectory = "D:\\configured-dats", + PreparedAssetPath = "D:\\configured-dats\\acdream.pak", + }, + }, + }; + var session = new SessionDescriptor + { + Id = "content-session", + Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 }, + Account = "account", + Credential = new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.Environment, + Reference = "X", + }, + }; + + RuntimeOptions options = RuntimeOptions.FromSessionConfig( + "D:\\configured-dats", + _ => null, + "session.json", + config, + session, + "password"); + + Assert.Equal( + "D:\\configured-dats\\acdream.pak", + options.PreparedAssetPath); + } + + [Fact] + public void EnvironmentFlowLeavesEveryNewFieldAtItsNothingConfiguredDefault() + { + RuntimeOptions options = RuntimeOptions.Parse("D:\\dat", _ => null); + + Assert.Null(options.SessionConfigPath); + Assert.Null(options.SessionId); + Assert.Null(options.LiveCharacterSelector); + Assert.Null(options.StatusFilePath); + Assert.Null(options.Plugins); + Assert.Empty(options.LoginCommands); + Assert.Equal(500, options.LoginCommandDelayMs); + } +} diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs new file mode 100644 index 00000000..b5b44ebd --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs @@ -0,0 +1,225 @@ +using System.Runtime.CompilerServices; +using AcDream.App.Configuration; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1: proves the App config reader accepts the EXACT +/// document the Headless reader also accepts — +/// tests/Fixtures/campaign-la/session-config-shared-fixture.json is +/// parsed by both here and +/// AcDream.Headless.Configuration.HeadlessConfigurationLoader in +/// AcDream.Headless.Tests's twin of this test. This is the +/// pinned-contract acceptance test from +/// docs/plans/2026-08-14-launcher-campaign.md LA1: "a SHARED fixture +/// JSON parsed by both test suites proving the two readers accept the +/// identical document." If either reader's DTO shape drifts from the pinned +/// contract, ONE of these two tests fails. +/// +public sealed class SessionConfigurationSharedFixtureTests +{ + [Fact] + public void AppReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields() + { + (SessionConfiguration configuration, SessionDescriptor session) = + SessionConfigurationLoader.Load(SharedFixturePath()); + + Assert.Equal(1, configuration.Version); + Assert.Equal("shared-fixture", session.Id); + Assert.Equal("127.0.0.1", session.Endpoint.Host); + Assert.Equal(9000, session.Endpoint.Port); + Assert.Equal("sharedaccount", session.Account); + Assert.Equal("SharedToon", session.Character?.Name); + // App parses the policy field structurally but never consults it — + // the pinned contract's "parsed-and-ignored" clause. + Assert.Equal("idle", session.Policy?.Id); + Assert.Equal( + SessionCredentialProviderKind.Environment, + session.Credential.Provider); + Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference); + + Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins); + Assert.Equal( + ["/tell someone, hi", "/vt start"], + session.LoginCommands); + Assert.Equal(750, session.LoginCommandDelayMs); + Assert.Equal("shared-fixture-status.jsonl", session.StatusFile); + } + + [Fact] + public void AbsentLaunchContractFieldsFallBackToPinnedDefaults() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "no-launch-contract-fields", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" } + } + ] + } + """); + + (_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path); + + Assert.Null(session.Character); + Assert.Null(session.Plugins); + Assert.Null(session.LoginCommands); + Assert.Equal(500, session.LoginCommandDelayMs); + Assert.Null(session.StatusFile); + } + + [Fact] + public void MoreThanOneSessionFailsLoadForTheGraphicalHost() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "one", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "A" } + }, + { + "id": "two", + "endpoint": { "host": "127.0.0.1", "port": 9001 }, + "account": "account2", + "credential": { "provider": "environment", "reference": "B" } + } + ] + } + """); + + Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void EmptyPluginsEntryFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-plugins", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" }, + "plugins": ["Ok", " "] + } + ] + } + """); + + Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void NegativeLoginCommandDelayFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-delay", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" }, + "loginCommandDelayMs": -1 + } + ] + } + """); + + Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void BlankStatusFileFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-status-file", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" }, + "statusFile": " " + } + ] + } + """); + + Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + } + + internal static string SharedFixturePath( + [CallerFilePath] string sourcePath = "") => + Path.Combine( + FindRepositoryRoot(sourcePath), + "tests", + "Fixtures", + "campaign-la", + "session-config-shared-fixture.json"); + + private static string FindRepositoryRoot(string sourcePath) + { + string[] starts = + { + Path.GetDirectoryName(sourcePath) ?? string.Empty, + Directory.GetCurrentDirectory(), + AppContext.BaseDirectory, + }; + foreach (string start in starts) + { + if (string.IsNullOrEmpty(start)) + continue; + + DirectoryInfo? directory = new(start); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException( + "Could not find AcDream.slnx above the working or output directory."); + } + + private sealed class TemporaryFile : IDisposable + { + private TemporaryFile(string path) => Path = path; + + internal string Path { get; } + + internal static TemporaryFile Create(string json) + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-app-la1-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + return new TemporaryFile(path); + } + + public void Dispose() => File.Delete(Path); + } +} diff --git a/tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs b/tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs new file mode 100644 index 00000000..49eb9820 --- /dev/null +++ b/tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs @@ -0,0 +1,167 @@ +using AcDream.App.Configuration; +using AcDream.App.Credentials; + +namespace AcDream.App.Tests.Credentials; + +/// +/// Campaign LA slice LA1: is a minimal +/// port of AcDream.Headless.Credentials.HeadlessCredentialResolver +/// scoped to the App session-config credential shape — see that file's own +/// doc for why it is an independent port rather than a shared reference. +/// Mirrors HeadlessCredentialResolverTests's coverage. +/// +public sealed class AppCredentialResolverTests +{ + [Fact] + public void EnvironmentSecretIsRedactedAndErasable() + { + const string variable = "ACDREAM_LA1_TEST_ENV_SECRET"; + const string secretValue = "test-secret-value"; + Environment.SetEnvironmentVariable(variable, secretValue); + try + { + var resolver = new AppCredentialResolver( + TextReader.Null, + Environment.CurrentDirectory, + isLinux: false); + + AppCredentialSecret secret = resolver.Resolve( + "session", + new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.Environment, + Reference = variable, + }); + + Assert.Equal(secretValue, secret.Reveal()); + Assert.DoesNotContain(secretValue, secret.ToString()); + secret.Dispose(); + Assert.True(secret.IsDisposed); + Assert.Throws(secret.Reveal); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Fact] + public void StandardInputConsumesOneSecretWithoutEchoingIt() + { + const string secretValue = "stdin-secret"; + var resolver = new AppCredentialResolver( + new StringReader(secretValue + Environment.NewLine), + Environment.CurrentDirectory, + isLinux: false); + + using AppCredentialSecret secret = resolver.Resolve( + "session", + new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.StandardInput, + Reference = "session-stdin", + }); + + Assert.Equal(secretValue, secret.Reveal()); + Assert.DoesNotContain(secretValue, secret.ToString()); + } + + [Fact] + public void CredentialFileIsResolvedRelativeToConfiguredDirectory() + { + string directory = Path.Combine( + Path.GetTempPath(), + $"acdream-app-credentials-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "session.pass"); + File.WriteAllText(path, "file-secret" + Environment.NewLine); + try + { + var resolver = new AppCredentialResolver( + TextReader.Null, + directory, + isLinux: false); + + using AppCredentialSecret secret = resolver.Resolve( + "session", + new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.File, + Reference = "session.pass", + }); + + Assert.Equal("file-secret", secret.Reveal()); + } + finally + { + File.Delete(path); + Directory.Delete(directory); + } + } + + [Fact] + public void MissingSecretErrorNeverContainsAnotherSecret() + { + const string variable = "ACDREAM_LA1_TEST_OTHER_SECRET"; + const string unrelatedSecret = "must-not-leak"; + Environment.SetEnvironmentVariable(variable, unrelatedSecret); + try + { + var resolver = new AppCredentialResolver( + new StringReader(string.Empty), + Environment.CurrentDirectory, + isLinux: false); + + AppCredentialException error = + Assert.Throws(() => + resolver.Resolve( + "session", + new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.Environment, + Reference = "ACDREAM_LA1_TEST_DOES_NOT_EXIST", + })); + + Assert.DoesNotContain(unrelatedSecret, error.ToString()); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Fact] + public void LinuxRejectsGroupOrOtherCredentialPermissions() + { + if (!OperatingSystem.IsLinux()) + return; + + string path = Path.Combine( + Path.GetTempPath(), + $"acdream-app-credential-{Guid.NewGuid():N}"); + File.WriteAllText(path, "linux-secret"); + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.GroupRead); + try + { + var resolver = new AppCredentialResolver( + TextReader.Null, + Path.GetDirectoryName(path)!, + isLinux: true); + + Assert.Throws(() => + resolver.Resolve( + "session", + new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.File, + Reference = Path.GetFileName(path), + })); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs index 50852c52..a11e096d 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs @@ -97,6 +97,7 @@ public sealed class LiveSessionShutdownIntegrationTests RuntimeGenerationToken retiringGeneration) { } public void ReportConnecting(string host, int port, string user) { } public void ReportConnected() { } + public void ReportRoster(LiveSessionRosterReport roster) { } public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { } public void DetachSession(WorldSession session) { } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 237e24e4..33dafd1b 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -917,7 +917,9 @@ public sealed class CurrentGameRuntimeAdapterTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), new LiveSessionConnectOptions( true, "127.0.0.1", diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs index cd9d3f2c..a2586870 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs @@ -222,7 +222,9 @@ public sealed class HeadlessSessionEventRouteRetryPendingTests new LiveSessionEnteredWorldBindings( _ => { }, () => { }, () => { }, _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); LiveSessionStartResult startResult = live.Start(options); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index f2016f17..5c9871f5 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Net; using System.Numerics; using System.Reflection; +using System.Text.Json; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -61,6 +62,95 @@ public sealed class HeadlessSessionHostTests Assert.DoesNotContain("AcDream.App", diagnostics); } + /// + /// Campaign LA slice LA1: proves the status-event writer fires the + /// pinned lifecycle vocabulary — started/connected/characterList/ + /// enteredWorld/disconnected/exited — in order, from a real + /// start+dispose cycle, and that the + /// roster surfaced matches + /// exactly (before selection has happened — the roster is reported for + /// BOTH candidates, not just the selected one). + /// + [Fact] + public void StatusFileReceivesThePinnedLifecycleEventsInOrder() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-status-{Guid.NewGuid():N}.jsonl"); + try + { + var operations = new FixtureSessionOperations(); + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(statusFile: statusPath), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + RuntimeSessionStartResult started = host.Start(); + Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status); + host.Dispose(); + + string[] lines = File.ReadAllLines(statusPath); + string[] eventNames = lines + .Select(line => JsonDocument.Parse(line) + .RootElement.GetProperty("e").GetString()!) + .ToArray(); + Assert.Equal( + [ + "started", "connected", "characterList", "enteredWorld", + "disconnected", "exited", + ], + eventNames); + + using JsonDocument characterListDoc = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "characterList")]); + JsonElement characterList = characterListDoc.RootElement; + Assert.Equal("account", characterList.GetProperty("accountName").GetString()); + Assert.Equal(2, characterList.GetProperty("characters").GetArrayLength()); + + using JsonDocument enteredWorldDoc = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "enteredWorld")]); + Assert.Equal( + 0x50000002u, + enteredWorldDoc.RootElement.GetProperty("characterId").GetUInt32()); + + using JsonDocument exitedDoc = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "exited")]); + Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32()); + + string contents = File.ReadAllText(statusPath); + Assert.DoesNotContain("password", contents, StringComparison.Ordinal); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + + [Fact] + public void AbsentStatusFileConstructsANoOpWriter() + { + var operations = new FixtureSessionOperations(); + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + RuntimeSessionStartResult started = host.Start(); + + Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status); + // No exception, and (implicitly) no file was ever touched — the + // writer is a permanent no-op with no configured path. + } + [Fact] public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() { @@ -1937,7 +2027,9 @@ public sealed class HeadlessSessionHostTests _ => { }, () => { }), (_, _, _) => { }, - () => { })); + () => { }, + _ => { }, + _ => { })); } private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations @@ -1967,7 +2059,8 @@ public sealed class HeadlessSessionHostTests HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind.Environment, string credentialReference = "BOT_PASSWORD", - Dictionary? characterOptions = null) => new() + Dictionary? characterOptions = null, + string? statusFile = null) => new() { Id = "bot", Endpoint = new HeadlessEndpointDescriptor @@ -1990,6 +2083,7 @@ public sealed class HeadlessSessionHostTests Reference = credentialReference, }, CharacterOptions = characterOptions, + StatusFile = statusFile, }; private static void HydrateGroundedPlayer(GameRuntime runtime) diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs new file mode 100644 index 00000000..631a6c1d --- /dev/null +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -0,0 +1,206 @@ +using System.Runtime.CompilerServices; +using AcDream.Headless.Configuration; + +namespace AcDream.Headless.Tests; + +/// +/// Campaign LA slice LA1: proves the Headless config reader accepts the +/// EXACT document the App reader also accepts — +/// tests/Fixtures/campaign-la/session-config-shared-fixture.json is +/// parsed by both here and +/// AcDream.App.Configuration.SessionConfigurationLoader in +/// AcDream.App.Tests's twin of this test. This is the pinned-contract +/// acceptance test from docs/plans/2026-08-14-launcher-campaign.md +/// LA1: "a SHARED fixture JSON parsed by both test suites proving the two +/// readers accept the identical document." If either reader's DTO shape +/// drifts from the pinned contract, ONE of these two tests fails. +/// +public sealed class SessionConfigurationSharedFixtureTests +{ + [Fact] + public void HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields() + { + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(SharedFixturePath()); + + HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!; + Assert.Equal("shared-fixture", session.Id); + Assert.Equal("127.0.0.1", session.Endpoint.Host); + Assert.Equal(9000, session.Endpoint.Port); + Assert.Equal("sharedaccount", session.Account); + Assert.Equal("SharedToon", session.Character.Name); + Assert.Equal("idle", session.Policy.Id); + Assert.Equal( + HeadlessCredentialProviderKind.Environment, + session.Credential.Provider); + Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference); + + Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins); + Assert.Equal( + ["/tell someone, hi", "/vt start"], + session.LoginCommands); + Assert.Equal(750, session.LoginCommandDelayMs); + Assert.Equal("shared-fixture-status.jsonl", session.StatusFile); + } + + [Fact] + public void AbsentLaunchContractFieldsFallBackToPinnedDefaults() + { + // Every LA1 field is optional; a document that omits all five must + // still load, with loginCommandDelayMs defaulting to the pinned + // 500 ms and the rest defaulting to "nothing configured". + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "no-launch-contract-fields", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "X" } + } + ] + } + """); + + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(file.Path); + + HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!; + Assert.Null(session.Plugins); + Assert.Null(session.LoginCommands); + Assert.Equal(500, session.LoginCommandDelayMs); + Assert.Null(session.StatusFile); + } + + [Fact] + public void EmptyPluginsEntryFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-plugins", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "X" }, + "plugins": ["Ok", " "] + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void NegativeLoginCommandDelayFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-delay", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "X" }, + "loginCommandDelayMs": -1 + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void BlankStatusFileFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-status-file", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "X" }, + "statusFile": " " + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + + internal static string SharedFixturePath( + [CallerFilePath] string sourcePath = "") => + Path.Combine( + FindRepositoryRoot(sourcePath), + "tests", + "Fixtures", + "campaign-la", + "session-config-shared-fixture.json"); + + private static string FindRepositoryRoot(string sourcePath) + { + string[] starts = + { + Path.GetDirectoryName(sourcePath) ?? string.Empty, + Directory.GetCurrentDirectory(), + AppContext.BaseDirectory, + }; + foreach (string start in starts) + { + if (string.IsNullOrEmpty(start)) + continue; + + DirectoryInfo? directory = new(start); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException( + "Could not find AcDream.slnx above the working or output directory."); + } + + private sealed class TemporaryFile : IDisposable + { + private TemporaryFile(string path) => Path = path; + + internal string Path { get; } + + internal static TemporaryFile Create(string json) + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-headless-la1-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + return new TemporaryFile(path); + } + + public void Dispose() => File.Delete(Path); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index cdb95ec3..c2f4fe6d 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -55,7 +55,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); adapter = new DirectGameRuntimeCommandAdapter(runtime, live); var trace = new RuntimeTraceRecorder(); @@ -752,7 +754,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); adapter = new DirectGameRuntimeCommandAdapter(runtime, live); _ = adapter.Session.Start(runtime.Generation); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index adeaa109..6a3f49bf 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -135,6 +135,7 @@ public sealed class LiveSessionControllerTests public Action? OnReset { get; set; } public Action? OnConnecting { get; set; } public Action? OnConnected { get; set; } + public Action? OnRoster { get; set; } public Action? OnSelected { get; set; } public Action? OnActivate { get; set; } public Action? OnEntered { get; set; } @@ -146,6 +147,7 @@ public sealed class LiveSessionControllerTests public bool ThrowOnBind { get; set; } public bool ThrowOnConnecting { get; set; } public bool ThrowOnConnected { get; set; } + public bool ThrowOnRoster { get; set; } public bool ThrowOnSelected { get; set; } public bool ThrowOnActivate { get; set; } public bool ThrowOnEntered { get; set; } @@ -158,6 +160,7 @@ public sealed class LiveSessionControllerTests public List CommandBuses { get; } = []; public List Selections { get; } = []; public List ResetGenerations { get; } = []; + public List Rosters { get; } = []; public LiveSessionBinding BindSession(WorldSession session) { @@ -231,6 +234,15 @@ public sealed class LiveSessionControllerTests throw new InvalidOperationException("connected failure"); } + public void ReportRoster(LiveSessionRosterReport roster) + { + calls.Add("roster"); + Rosters.Add(roster); + OnRoster?.Invoke(); + if (ThrowOnRoster) + throw new InvalidOperationException("roster failure"); + } + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { calls.Add("selected"); @@ -290,7 +302,7 @@ public sealed class LiveSessionControllerTests Assert.Equal( [ "reset", "resolve", "create", "bind", "report-connecting", - "connect", "report-connected", "selected", "enter:1", + "connect", "report-connected", "roster", "selected", "enter:1", "activate", "entered", ], calls); @@ -302,6 +314,32 @@ public sealed class LiveSessionControllerTests Assert.True(host.CommandBuses[0].Active); } + [Fact] + public void Start_ReportsRosterFromCharacterListBeforeSelection() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + LiveSessionRosterReport roster = Assert.Single(host.Rosters); + Assert.Equal("Canonical", roster.AccountName); + Assert.Equal(11, roster.SlotCount); + Assert.Equal( + [ + new LiveSessionRosterEntry(0x50000001u, "Grey", 10u), + new LiveSessionRosterEntry(0x50000002u, "Ready", 0u), + ], + roster.Entries); + // "roster" must land strictly before "selected" — the launcher's + // char-select screen (LA7/LA8) will read the roster before any + // selection has been made. + Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected")); + } + [Fact] public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession() { @@ -488,7 +526,7 @@ public sealed class LiveSessionControllerTests [ "deactivate", "detach-events", "dispose-session", "detach-session", "reset", "resolve", "create", "bind", "report-connecting", - "connect", "report-connected", "selected", "enter:1", + "connect", "report-connected", "roster", "selected", "enter:1", "activate", "entered", ], calls); @@ -742,6 +780,7 @@ public sealed class LiveSessionControllerTests [InlineData("connecting")] [InlineData("connected")] [InlineData("characters")] + [InlineData("roster")] [InlineData("selected")] [InlineData("activate")] [InlineData("entered")] @@ -755,6 +794,7 @@ public sealed class LiveSessionControllerTests case "connecting": host.ThrowOnConnecting = true; break; case "connected": host.ThrowOnConnected = true; break; case "characters": operations.ThrowOnCharacters = true; break; + case "roster": host.ThrowOnRoster = true; break; case "selected": host.ThrowOnSelected = true; break; case "activate": host.ThrowOnActivate = true; break; case "entered": host.ThrowOnEntered = true; break; diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs index 1cda8f2f..31373a74 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs @@ -35,12 +35,13 @@ public sealed class LiveSessionHostTests Assert.Equal( [ "reset", "resolve", "create", "events", "attach-events", "commands", - "connecting", "connect", "connected", + "connecting", "connect", "connected", "roster:Canonical", "player:1342177282", "vitals:1342177282", "chat:1342177282", "persistent:1342177282", "vanish:1342177282", "clear-combat", "enter:1", "activate", "active:Ready", "restore-layout", "sync-toolbar", "load-settings:Ready", "arm-auto-entry", + "character-entered:1342177282", ], calls); Assert.Same(controller.CurrentSession, host.CurrentSession); @@ -236,7 +237,10 @@ public sealed class LiveSessionHostTests name => calls.Add($"load-settings:{name}"), () => calls.Add("arm-auto-entry")), Connecting: (_, _, _) => calls.Add("connecting"), - Connected: () => calls.Add("connected"))); + Connected: () => calls.Add("connected"), + Roster: roster => calls.Add($"roster:{roster.AccountName}"), + CharacterEntered: selection => + calls.Add($"character-entered:{selection.CharacterId}"))); private static LiveSessionConnectOptions LiveOptions( bool live = true, diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs index 74b36ae1..48064bc3 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs @@ -18,6 +18,7 @@ public sealed class LiveSessionLifecycleHostTests host.ResetSessionState(RuntimeGenerationToken.Initial); host.ReportConnecting("host", 9000, "user"); host.ReportConnected(); + host.ReportRoster(new LiveSessionRosterReport("account", 11, [])); var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account"); host.ApplySelectedCharacter(selection); binding.ActivateCommands(); @@ -31,8 +32,8 @@ public sealed class LiveSessionLifecycleHostTests Assert.Equal( [ "bind", "reset", "connecting:host:9000:user", - "connected", "selected:toon", "activate", "entered:toon", - "deactivate", "detach-events", "bind", + "connected", "roster:account", "selected:toon", "activate", + "entered:toon", "deactivate", "detach-events", "bind", ], calls); replacement.Dispose(); @@ -71,6 +72,7 @@ public sealed class LiveSessionLifecycleHostTests Connecting: (host, port, user) => calls.Add($"connecting:{host}:{port}:{user}"), Connected: () => calls.Add("connected"), + Roster: roster => calls.Add($"roster:{roster.AccountName}"), Selected: selection => calls.Add($"selected:{selection.CharacterName}"), Entered: selection => calls.Add($"entered:{selection.CharacterName}"))); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs index eeb6dfbf..21328b16 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs @@ -2342,7 +2342,9 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); LiveSessionStartResult startResult = live.Start(options); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 9f80fe45..925af86f 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -1049,7 +1049,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); LiveSessionStartResult startResult = live.Start(options); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs index 38fb9544..2c09dab7 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs @@ -34,7 +34,9 @@ public sealed class RuntimeLiveSessionNoWindowTests _ => { }, () => { }), (_, _, _) => calls.Add("connecting"), - () => calls.Add("connected")), + () => calls.Add("connected"), + _ => calls.Add("roster"), + selection => calls.Add($"character-entered:{selection.CharacterId}")), new LiveSessionConnectOptions( true, "127.0.0.1", @@ -61,10 +63,12 @@ public sealed class RuntimeLiveSessionNoWindowTests "connect", "connected", "characters", + "roster", "player:1342177281", "enter:0", "activate-commands", "entered:Runtime", + "character-entered:1342177281", "deactivate-commands", "detach-events", "dispose-session", diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs new file mode 100644 index 00000000..5b9e7133 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Session; + +/// +/// Campaign LA slice LA1: pins the exact JSONL status-stream contract both +/// the App and Headless hosts write into, and the launcher (a process we +/// don't own) reads — see docs/plans/2026-08-14-launcher-campaign.md +/// LA1 and docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +/// §6. +/// +public sealed class SessionStatusWriterTests +{ + [Fact] + public void EachEventWritesTheExactPinnedShapeInOrder() + { + using TemporaryFile file = TemporaryFile.Create(); + var writer = new SessionStatusWriter(file.Path); + + writer.Started("s1"); + writer.Connected("s1"); + writer.CharacterList( + "s1", + new LiveSessionRosterReport( + "account", + 11, + [ + new LiveSessionRosterEntry(0x50000001u, "Ready", 0u), + new LiveSessionRosterEntry(0x50000002u, "Grey", 10u), + ])); + writer.EnteredWorld("s1", 0x50000001u, "Ready"); + writer.Disconnected("s1", "stopped"); + writer.Exited("s1", 0, "disposed"); + + string[] lines = File.ReadAllLines(file.Path); + Assert.Equal(6, lines.Length); + + JsonElement started = Parse(lines[0]); + Assert.Equal(1, started.GetProperty("v").GetInt32()); + Assert.Equal("started", started.GetProperty("e").GetString()); + Assert.True(started.TryGetProperty("t", out _)); + Assert.Equal("s1", started.GetProperty("sessionId").GetString()); + + JsonElement connected = Parse(lines[1]); + Assert.Equal("connected", connected.GetProperty("e").GetString()); + Assert.Equal("s1", connected.GetProperty("sessionId").GetString()); + + JsonElement characterList = Parse(lines[2]); + Assert.Equal("characterList", characterList.GetProperty("e").GetString()); + Assert.Equal("account", characterList.GetProperty("accountName").GetString()); + Assert.Equal(11, characterList.GetProperty("slotCount").GetInt32()); + JsonElement characters = characterList.GetProperty("characters"); + Assert.Equal(2, characters.GetArrayLength()); + JsonElement first = characters[0]; + Assert.Equal(0x50000001u, first.GetProperty("id").GetUInt32()); + Assert.Equal("Ready", first.GetProperty("name").GetString()); + Assert.Equal(0u, first.GetProperty("secondsGreyedOut").GetUInt32()); + + JsonElement enteredWorld = Parse(lines[3]); + Assert.Equal("enteredWorld", enteredWorld.GetProperty("e").GetString()); + Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32()); + Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString()); + + JsonElement disconnected = Parse(lines[4]); + Assert.Equal("disconnected", disconnected.GetProperty("e").GetString()); + Assert.Equal("stopped", disconnected.GetProperty("reason").GetString()); + + JsonElement exited = Parse(lines[5]); + Assert.Equal("exited", exited.GetProperty("e").GetString()); + Assert.Equal(0, exited.GetProperty("code").GetInt32()); + Assert.Equal("disposed", exited.GetProperty("reason").GetString()); + } + + [Fact] + public void NoOpWriterNeverCreatesAFile() + { + using TemporaryFile file = TemporaryFile.Reserve(); + var writer = new SessionStatusWriter(null); + + writer.Started("s1"); + writer.Connected("s1"); + writer.Disconnected("s1", "stopped"); + writer.Exited("s1", 0, "disposed"); + + Assert.False(writer.IsEnabled); + Assert.False(File.Exists(file.Path)); + } + + [Fact] + public void BlankPathIsTreatedAsAbsent() + { + var writer = new SessionStatusWriter(" "); + + Assert.False(writer.IsEnabled); + // Must not throw even though there is no real path behind it. + writer.Started("s1"); + } + + [Fact] + public void PasswordNeverAppearsInTheStatusStream() + { + using TemporaryFile file = TemporaryFile.Create(); + var writer = new SessionStatusWriter(file.Path); + + writer.Started("bot"); + writer.Connected("bot"); + writer.CharacterList( + "bot", + new LiveSessionRosterReport( + "account-name", + 11, + [new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)])); + writer.EnteredWorld("bot", 0x50000001u, "Ready"); + writer.Disconnected("bot", "stopped"); + writer.Exited("bot", 0, "disposed"); + + string contents = File.ReadAllText(file.Path); + Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal); + Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FileIsOpenedShareReadSoAConcurrentTailerCanReadWhileAppending() + { + using TemporaryFile file = TemporaryFile.Create(); + var writer = new SessionStatusWriter(file.Path); + writer.Started("s1"); + + // A concurrent reader (the launcher's tailer) must be able to open + // the file for read while the writer holds it — FileShare.Read on + // the writer side is what this test is pinning. + using FileStream tailer = new( + file.Path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite); + using var tailerReader = new StreamReader(tailer); + string? firstLine = tailerReader.ReadLine(); + Assert.NotNull(firstLine); + Assert.Contains("\"started\"", firstLine); + + // The writer keeps working while the tailer's handle is still open. + writer.Connected("s1"); + string? secondLine = tailerReader.ReadLine(); + Assert.NotNull(secondLine); + Assert.Contains("\"connected\"", secondLine); + } + + private static JsonElement Parse(string line) => + JsonDocument.Parse(line).RootElement; + + private sealed class TemporaryFile : IDisposable + { + private TemporaryFile(string path) => Path = path; + + internal string Path { get; } + + internal static TemporaryFile Create() + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-status-{Guid.NewGuid():N}.jsonl"); + return new TemporaryFile(path); + } + + /// A path that is never actually created — used by the + /// no-op test to assert the writer truly never touches disk. + internal static TemporaryFile Reserve() => Create(); + + public void Dispose() + { + if (File.Exists(Path)) + File.Delete(Path); + } + } +} diff --git a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs index 0603b0b8..8074f6a4 100644 --- a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs +++ b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs @@ -72,7 +72,9 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable host, port, connectingUser), - _operations.RecordConnected), + _operations.RecordConnected, + _operations.RecordRoster, + _operations.RecordCharacterEntered), new LiveSessionConnectOptions( true, "127.0.0.1", @@ -693,6 +695,13 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable Trace.Add($"connecting:{host}:{port}:{user}"); public void RecordConnected() => Trace.Add("connected"); + + public void RecordRoster(LiveSessionRosterReport roster) => + Trace.Add($"roster:{roster.AccountName}"); + + public void RecordCharacterEntered( + LiveSessionCharacterSelection selection) => + Trace.Add($"character-entered:{selection.CharacterId}"); } private sealed class FixtureTransport : IWorldSessionTransport diff --git a/tests/Fixtures/campaign-la/session-config-shared-fixture.json b/tests/Fixtures/campaign-la/session-config-shared-fixture.json new file mode 100644 index 00000000..822921a6 --- /dev/null +++ b/tests/Fixtures/campaign-la/session-config-shared-fixture.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "sessions": [ + { + "id": "shared-fixture", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "sharedaccount", + "character": { "name": "SharedToon" }, + "policy": { "id": "idle" }, + "credential": { + "provider": "environment", + "reference": "SHARED_FIXTURE_PASSWORD" + }, + "plugins": ["ExamplePlugin", "AnotherPlugin"], + "loginCommands": ["/tell someone, hi", "/vt start"], + "loginCommandDelayMs": 750, + "statusFile": "shared-fixture-status.jsonl" + } + ] +} From e1322a06ae028ce43bf4e32985213495a657a207 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:05:17 +0200 Subject: [PATCH 013/138] =?UTF-8?q?note:=20db9ad53c=20is=20a=20MIXED=20com?= =?UTF-8?q?mit=20=E2=80=94=20docs=20+=20LA1=20in-progress=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The running LA1 implementer had pre-staged its work-in-progress; git commit takes the whole index regardless of what git add named, so the docs commit swept in 37 LA1 files mid-implementation. Content is intact; LA1 completion commit(s) follow with the remainder, and the LA1 slice review covers the combined range. Process rule adopted: no orchestrator commits in a worktree while an implementer agent is live in it. Co-Authored-By: Claude Fable 5 From 0c8643a7f2c557968c8dee395705968151371e31 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:05:51 +0200 Subject: [PATCH 014/138] =?UTF-8?q?docs:=20AD-97=20wording=20nit=20from=20?= =?UTF-8?q?LA7a=20narrow=20re-review=20=E2=80=94=20correct=20artifact-appe?= =?UTF-8?q?arance=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/architecture/retail-divergence-register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 7fd0b897..f9b38e6b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -189,7 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | -| 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` (4 of its 5 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-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 | --- From 498f1c1182b52f41370e268a53be804d5df76138 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:08:45 +0200 Subject: [PATCH 015/138] =?UTF-8?q?docs:=20Campaign=20LA=20ledger=20?= =?UTF-8?q?=E2=80=94=20LA7a=20DONE+merged=20(fa2de1c4);=20LA1=20implemente?= =?UTF-8?q?d,=20review=20dispatching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 9988b8c4..c904885e 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -477,13 +477,13 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | -| LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields | +| LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers | | LA2 | — | | | | | LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | | LA4 | — | | | | | LA5 | — | | | | | LA6 | — | | | | -| LA7 | LA7a implemented (`6a32f375`, campaign-la7a); Opus retail-lens review in flight. LA7b (state+flow) waits on LA1 | `6a32f375` | review in flight | 46 new byte-exact tests; Core.Net 951/0/0. Reviewer verifying: conditional 0xF643 parse, retail 26-member charError enum, uninit-edx artifact proof | +| LA7 | **LA7a DONE + MERGED** (`fa2de1c4`); LA7b (state+flow) unblocked by LA1 | `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4` | Opus retail-lens PASS; narrow re-review MERGE; AD-97 filed | Review decoded the retail binary: restore is ≥16 bytes, guid-only is an ADAPTATION (AD-97); conditional 0xF643 parse CONFIRMED; enum corrects ACE's 0x08 misnaming. Core.Net 953/0/0 post-merge | | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | From 26feba818651a1e7dedf7a292afd6b386f5e6ee2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:29:39 +0200 Subject: [PATCH 016/138] =?UTF-8?q?fix(launcher):=20Campaign=20LA=20LA3=20?= =?UTF-8?q?review=20fixes=20=E2=80=94=20contract=20paths=20omission,=20pro?= =?UTF-8?q?be=20composition,=20graceful=20stop,=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus review of LA3 returned FIX FIRST; this addresses every finding in scope (F1-F5, F7-F12; F6 CI-lane addition excluded per instructions): - F1 (CRITICAL): SessionProcessSettings.Paths is now nullable and left null by SessionConfigComposer unless a caller supplies overrides, so the JSON key is entirely absent instead of "paths":{} — the App-side loader's strict UnmappedMemberHandling.Disallow would otherwise reject every gui/guiSelect session-config document at load. - F2: added SessionConfigComposer.ComposeProbe and a nullable SessionDescriptor.Mode field ("probe", omitted for normal play) per the pinned contract — no character/policy/plugins/loginCommands. - F3: LauncherProcessSupervisor.Stop now tries ILauncherChildProcess.TryRequestGracefulStop (Linux: libc SIGINT via LibraryImport, K4-proven graceful headless logout) before CloseMainWindow. Windows has no reliable no-window-console equivalent today; filed docs/ISSUES.md #397 with the CREATE_NEW_PROCESS_GROUP + CTRL_BREAK fix direction. Stop()'s blocking-timeout contract is now documented for LA4. - F4: LauncherProfileStore.Save chmods the Linux temp file to 0600 immediately after creation, before any credential is serialized; failure paths and Load() clean up a stale .tmp. - F5: added LauncherCoreDependencyBoundaryTests asserting Launcher.Core references exactly AcDream.Platform and no packages. - F7: StatusEventParser.Parse no longer throws on a whitespace/null line; StatusFileTailer.ReadNewEvents swallows the File.Exists/open TOCTOU window (FileNotFoundException/DirectoryNotFoundException/ IOException) instead of throwing. - F8: Start() now kills (entire process tree) and disposes a child that started successfully but failed while being fed its stdin password, instead of orphaning it. - F9: SetState is monotonic — once Exited, no later transition applies or fires StateChanged, closing a Start()-path race where a synchronously-exiting child could be "resurrected" to Running. - F10: CharacterIdFormat.TryParse now requires the "0x" prefix (an unprefixed hand-typed decimal id is also valid hex and was silently misread); a parsed id of 0 is treated as unusable and falls back to the name selector; LauncherProfileStore.MergeRoster normalizes both sides through TryParse/ToHexString instead of raw string equality, so a legacy unprefixed-hex row self-heals via name match instead of duplicating. - F11: StatusCharacterEntry.SecondsGreyedOut is now uint, matching CharacterRosterEntry and the host writer. - F12: added MalformedStatusEvent, returned for a recognized `e` whose payload doesn't match its shape, distinguished from UnknownStatusEvent (an unrecognized `e`). AllowUnsafeBlocks was added to AcDream.Launcher.Core.csproj — required by the LibraryImport source generator's function-pointer marshalling stub for F3's Linux SIGINT P/Invoke. Verification: dotnet build AcDream.slnx -c Release green (0 errors); dotnet test tests/AcDream.Launcher.Core.Tests -c Release green at 94/94 on native Windows and under WSL (Ubuntu, verified across multiple runs for the timing-sensitive SIGINT/sharing-violation tests, no flakes observed). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 51 +++++ .../AcDream.Launcher.Core.csproj | 6 + .../Launching/ILauncherChildProcess.cs | 56 ++++- .../Launching/LauncherProcessSupervisor.cs | 63 +++++- .../Launching/SessionConfigComposer.cs | 93 ++++++++- .../Launching/SessionConfigDocument.cs | 20 +- .../Profiles/CharacterIdFormat.cs | 15 +- .../Profiles/LauncherProfileStore.cs | 86 ++++++-- .../Status/StatusEvent.cs | 17 +- .../Status/StatusEventParser.cs | 134 +++++++----- .../Status/StatusFileTailer.cs | 25 ++- .../LauncherCoreDependencyBoundaryTests.cs | 79 +++++++ .../LauncherProcessSupervisorTests.cs | 193 +++++++++++++++++- .../Launching/SessionConfigComposerTests.cs | 139 ++++++++++++- .../Profiles/CharacterIdFormatTests.cs | 13 +- .../Profiles/LauncherProfileStoreTests.cs | 102 +++++++++ .../Profiles/RosterMergeTests.cs | 26 +++ .../Status/StatusEventParserTests.cs | 61 +++++- .../Status/StatusFileTailerTests.cs | 27 +++ 19 files changed, 1101 insertions(+), 105 deletions(-) create mode 100644 tests/AcDream.Launcher.Core.Tests/LauncherCoreDependencyBoundaryTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index f39bc608..881ed834 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,57 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host + +**Status:** OPEN +**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE +account session stuck for several minutes — a documented project landmine; +see CLAUDE.md "Logout-before-reconnect") +**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3) +**Component:** Launcher.Core / process supervision + +**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful +stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE +`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke +(`kill(pid, 2)`), which the K4-proven headless host already turns into an +ACE-confirmed graceful logout. On Windows there is no equivalent today for a +console process with no message-pump window: `CloseMainWindow` is a no-op +for a console host (there is no `HWND` to target), and +`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process +today — Windows delivers console control events to every process attached +to the SAME console as the calling process, so an unscoped call would also +signal the launcher itself (and anything else sharing that console), not +just the intended child. `TryRequestGracefulStop` therefore returns `false` +on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow` +(still a no-op for a console child) and then the timeout-driven `Kill()` — +exactly the hard-kill behavior this finding was written to describe, just +with a documented (rather than silent) gap. + +**Known fix direction (not yet implemented).** Spawn the Windows child with +the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native +`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process` +plumbing in `SystemChildProcess`) so the child gets its own console process +group, detached from the launcher's own group. Then +`TryRequestGracefulStop` on Windows calls +`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)` — +`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and, +unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a +process that has installed no console-control handler at all (the default +CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't +strictly need new code to receive SOME form of shutdown from it) — though +wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into +the same graceful-shutdown path K4 already built for Linux SIGINT is the +better long-term target, so a Windows headless launch gets the identical +ACE-confirmed graceful logout instead of just "exits somehow." + +**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows +children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends +`CTRL_BREAK_EVENT` to that child's process group on Windows; a live +connected gate proves `AcDream.Headless` exits gracefully (ACE clears the +session immediately, not after the ~3-minute stale-session window) when +stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the +Linux SIGINT behavior. + ## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click **Status:** ROOT-CAUSED + FIXED — pending the user's visual re-gate of the diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj index 89be4968..f31af6e8 100644 --- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -5,6 +5,12 @@ enable latest true + + true diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs index 7ee3ed95..872c0130 100644 --- a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.InteropServices; namespace AcDream.Launcher.Core.Launching; @@ -28,6 +29,26 @@ public interface ILauncherChildProcess : IDisposable void Start(); + /// + /// Attempts a graceful stop signal appropriate to the platform, + /// tried BEFORE (Campaign LA plan §LA3 + /// review finding F3): a no-window console host (e.g. + /// AcDream.Headless) never has a main window for + /// to close, so without this step + /// always degraded + /// straight to a timeout + hard — and a hard kill + /// leaves the ACE account session stuck for several minutes (a + /// documented project landmine; see CLAUDE.md + /// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved + /// the headless host's SIGINT handler produces an ACE-confirmed + /// graceful logout). On Windows there is no reliable cross-console + /// mechanism for an arbitrary no-window child process today — see + /// docs/ISSUES.md for the tracked gap and fix direction; this + /// returns false there. Returns true only when the signal was + /// actually delivered; never throws. + /// + bool TryRequestGracefulStop(); + /// Mirrors — requests /// a graceful close via WM_CLOSE. Returns false for a console/no- /// window process (never throws), matching the real API. @@ -54,8 +75,16 @@ public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory new SystemChildProcess(spec); } -internal sealed class SystemChildProcess : ILauncherChildProcess +internal sealed partial class SystemChildProcess : ILauncherChildProcess { + // SIGINT's numeric value (POSIX-stable across Linux distributions). + // K4/Slice K already proved the headless host's SIGINT handler + // produces an ACE-confirmed graceful logout. + private const int Sigint = 2; + + [LibraryImport("libc", SetLastError = true)] + private static partial int kill(int pid, int sig); + private readonly Process _process; private bool _raisingEnabled; @@ -99,6 +128,31 @@ internal sealed class SystemChildProcess : ILauncherChildProcess _process.Start(); } + public bool TryRequestGracefulStop() + { + if (!OperatingSystem.IsLinux()) + { + // No reliable cross-console mechanism exists for an + // arbitrary no-window Windows child process — tracked gap, + // see docs/ISSUES.md. + return false; + } + + try + { + return kill(_process.Id, Sigint) == 0; + } + catch + { + // Matches CloseMainWindow's "never throws" contract — the + // process may not have started yet, may have already exited + // (ESRCH), or the platform may lack libc under an unusual + // Linux runtime; any of these degrade to "signal not sent" + // rather than an exception out of Stop(). + return false; + } + } + public bool CloseMainWindow() => _process.CloseMainWindow(); public void Kill() => _process.Kill(entireProcessTree: true); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index c6749fdf..f9b10694 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -56,9 +56,11 @@ public sealed class LauncherProcessSupervisor : IDisposable SetState(LauncherSessionState.Starting); + bool started = false; try { process.Start(); + started = true; if (password is not null) { @@ -77,6 +79,28 @@ public sealed class LauncherProcessSupervisor : IDisposable _process = null; } + // A failure after the child actually started (e.g. the stdin + // pipe breaks while feeding the password) must not leave a + // live, unsupervised, undisposable child running (Campaign LA + // plan §LA3 review finding F8) — kill the whole process tree + // and release the handle before propagating the original + // failure. + if (started) + { + try + { + process.Kill(); + } + catch + { + // Best-effort — the ORIGINAL failure, rethrown below, + // is what the caller needs to see; a failed cleanup + // kill must not replace it. + } + } + + process.Dispose(); + throw; } @@ -84,10 +108,22 @@ public sealed class LauncherProcessSupervisor : IDisposable } /// - /// Requests a graceful stop (CloseMainWindow), falling back to Kill - /// if the process has not exited within . - /// A no-op if was never called or the process has - /// already exited. + /// Requests a graceful stop — first + /// (SIGINT + /// on Linux; a no-op on Windows today, see + /// 's docs), + /// then — falling + /// back to if the process has + /// not exited within . A no-op if + /// was never called or the process has already + /// exited. + /// + /// BLOCKS THE CALLING THREAD for up to + /// (via the real child's WaitForExit) — callers on a UI thread + /// must dispatch this off-thread rather than calling it directly (a + /// binding requirement for the LA4 Avalonia UI, which will call this + /// method from a "stop session" action). + /// /// public void Stop(TimeSpan timeout) { @@ -102,6 +138,7 @@ public sealed class LauncherProcessSupervisor : IDisposable return; } + process.TryRequestGracefulStop(); process.CloseMainWindow(); if (!process.WaitForExit(timeout) && !process.HasExited) { @@ -121,10 +158,28 @@ public sealed class LauncherProcessSupervisor : IDisposable SetState(LauncherSessionState.Exited); } + /// + /// Applies a state transition, or silently ignores it (Campaign LA + /// plan §LA3 review finding F9): once reaches the + /// terminal , no later call + /// may move it anywhere else, and only + /// fires for a transition that was actually applied. This matters + /// because 's trailing + /// SetState(LauncherSessionState.Running) can race a + /// synchronous callback fired from + /// inside itself (a child that dies immediately) + /// — without this guard, "Running" would silently resurrect a + /// process that has already reported its exit. + /// private void SetState(LauncherSessionState state) { lock (_gate) { + if (State == LauncherSessionState.Exited) + { + return; + } + State = state; } diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index 9bba0226..1b4349d2 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -52,13 +52,7 @@ public static class SessionConfigComposer ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - string sessionDirectory = Path.Combine( - paths.CacheDirectory, - "launcher", - "sessions", - sessionId); - string configFilePath = Path.Combine(sessionDirectory, "session.json"); - string statusFilePath = Path.Combine(sessionDirectory, "status.jsonl"); + (string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId); SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect ? null @@ -92,7 +86,69 @@ public static class SessionConfigComposer { Process = new SessionProcessSettings { - Paths = new SessionPathOverrides(), + Content = new SessionContentDescriptor + { + DatDirectory = install.DatDirectory, + PreparedAssetPath = install.PreparedAssetPath, + }, + }, + Sessions = [descriptor], + }; + + return new ComposedSessionConfig( + sessionId, + configFilePath, + statusFilePath, + document); + } + + /// + /// Builds a probe session-config document (Campaign LA plan §LA2/ + /// §LA3 review finding F2): the session carries mode: "probe", + /// no character selector, and no policy — the host + /// reports the account's character roster over the status stream and + /// exits without entering the world. plugins/loginCommands + /// don't apply to a probe and are always omitted, exactly like an + /// empty configured set on a normal session. + /// + public static ComposedSessionConfig ComposeProbe( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(account); + ArgumentNullException.ThrowIfNull(install); + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + (string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId); + + var descriptor = new SessionDescriptor + { + Id = sessionId, + Mode = "probe", + Endpoint = new SessionEndpointDescriptor + { + Host = server.Host, + Port = server.Port, + }, + Account = account.Account, + Character = null, + Policy = null, + Credential = new SessionCredentialDescriptor(), + Plugins = null, + LoginCommands = null, + LoginCommandDelayMs = null, + StatusFile = statusFilePath, + }; + + var document = new SessionConfigDocument + { + Process = new SessionProcessSettings + { Content = new SessionContentDescriptor { DatDirectory = install.DatDirectory, @@ -149,9 +205,28 @@ public static class SessionConfigComposer public static string Serialize(SessionConfigDocument document) => JsonSerializer.Serialize(document, SerializerOptions); + private static (string ConfigFilePath, string StatusFilePath) BuildSessionPaths( + ApplicationPathSet paths, + string sessionId) + { + string sessionDirectory = Path.Combine( + paths.CacheDirectory, + "launcher", + "sessions", + sessionId); + + return ( + Path.Combine(sessionDirectory, "session.json"), + Path.Combine(sessionDirectory, "status.jsonl")); + } + private static SessionCharacterSelector BuildSelector(CharacterProfile character) { - if (CharacterIdFormat.TryParse(character.Id, out uint id)) + // A parsed id of 0 is not a usable selector — both host loaders + // (App/Headless) reject `id: 0` outright, so falling through to + // the name selector here is the only shape that reaches a real + // character (Campaign LA plan §LA3 review finding F10). + if (CharacterIdFormat.TryParse(character.Id, out uint id) && id != 0) { return new SessionCharacterSelector { Id = id }; } diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs index fca7c970..0f013f53 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -29,7 +29,18 @@ public sealed class SessionConfigDocument public sealed class SessionProcessSettings { - public SessionPathOverrides Paths { get; init; } = new(); + /// + /// PINNED CONTRACT (Campaign LA plan §LA3 review, finding F1): the + /// paths KEY is entirely OMITTED from the written JSON unless + /// a caller explicitly supplies overrides — never an empty object. + /// The App-side loader parses with strict + /// UnmappedMemberHandling.Disallow and has no paths + /// member of its own, so an emitted "paths":{} is a null- + /// omission artifact (the object's own members are all optional and + /// omit cleanly, but the containing property was never null itself) + /// that would fail every gui/guiSelect launch at config load. + /// + public SessionPathOverrides? Paths { get; init; } public SessionContentDescriptor Content { get; init; } = new(); } @@ -65,6 +76,13 @@ public sealed class SessionDescriptor { public string Id { get; init; } = string.Empty; + /// Present only for a probe session ("probe", + /// Campaign LA plan §LA2/§LA3) — the host reports the account's + /// character roster and exits without entering the world. OMITTED + /// entirely for a normal gui/guiSelect/headless play session. + /// + public string? Mode { get; init; } + public SessionEndpointDescriptor Endpoint { get; init; } = new(); public string Account { get; init; } = string.Empty; diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs index 55570cd5..573ceef0 100644 --- a/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs +++ b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs @@ -13,6 +13,15 @@ public static class CharacterIdFormat public static string ToHexString(uint id) => "0x" + id.ToString("X8", CultureInfo.InvariantCulture); + /// + /// Parses as a hex character id — the + /// 0x prefix (case-insensitive) is REQUIRED (Campaign LA plan + /// §LA3 review finding F10). Every all-digit id is ALSO a valid hex + /// number (e.g. "12345678"), so accepting a bare unprefixed + /// string as hex silently reinterprets a hand-typed decimal id and + /// selects the wrong character; requiring the prefix makes "this is + /// hex" an explicit, unambiguous signal instead of a guess. + /// public static bool TryParse(string? text, out uint id) { id = 0; @@ -20,8 +29,10 @@ public static class CharacterIdFormat return false; ReadOnlySpan span = text.AsSpan().Trim(); - if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) - span = span[2..]; + if (!span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return false; + + span = span[2..]; return uint.TryParse( span, diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs index 2fac9f0b..26f3957e 100644 --- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -66,6 +66,13 @@ public sealed class LauncherProfileStore /// public bool Load() { + // Opportunistic cleanup of a stale ".tmp" left behind by a Save() + // that crashed between creating the temp file and the atomic + // rename (Campaign LA plan §LA3 review finding F4) — a stray + // temp file carries the same plaintext credentials as the real + // store and should not linger. + DeleteStaleTempFile(FilePath + ".tmp"); + if (!File.Exists(FilePath)) { Document = new LauncherProfileDocument(); @@ -108,9 +115,16 @@ public sealed class LauncherProfileStore /// /// Persists to via a /// write-then-atomic-rename so a crash mid-write never leaves a - /// truncated credentials file. On Linux, restricts the final file to - /// owner read/write (0600) per Campaign LA's plaintext-credential - /// decision (spec §5, decisions log). + /// truncated credentials file. On Linux, the temp file is chmod'd to + /// owner read/write (0600) immediately after creation — BEFORE any + /// plaintext credential is serialized into it — so there is no window + /// where the temp file carries the process umask's (potentially + /// world/group-readable) default permissions while holding a + /// password; the final path gets the same restriction after the + /// rename (Campaign LA's plaintext-credential decision, spec §5, + /// decisions log; the temp-file window itself is review finding F4). + /// A failure between temp-file creation and the rename deletes the + /// stale temp file rather than leaving it behind. /// public void Save() { @@ -121,12 +135,27 @@ public sealed class LauncherProfileStore } string tempPath = FilePath + ".tmp"; - using (FileStream stream = File.Create(tempPath)) + try { - JsonSerializer.Serialize(stream, Document, SerializerOptions); - } + using (FileStream stream = File.Create(tempPath)) + { + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode( + tempPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite); + } - File.Move(tempPath, FilePath, overwrite: true); + JsonSerializer.Serialize(stream, Document, SerializerOptions); + } + + File.Move(tempPath, FilePath, overwrite: true); + } + catch + { + DeleteStaleTempFile(tempPath); + throw; + } if (OperatingSystem.IsLinux()) { @@ -136,6 +165,23 @@ public sealed class LauncherProfileStore } } + private static void DeleteStaleTempFile(string tempPath) + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Best-effort cleanup only — the caller's own exception (a + // failed Save()) or the fresh Load() already in progress is + // what matters; a cleanup failure must not mask either. + } + } + // --- Server CRUD ----------------------------------------------- public ServerProfile AddServer(string name, string host, int port) @@ -312,16 +358,26 @@ public sealed class LauncherProfileStore foreach (CharacterRosterEntry entry in roster) { string idText = CharacterIdFormat.ToHexString(entry.Id); - CharacterProfile? existing = profile.Characters.Find( - character => string.Equals( - character.Id, - idText, - StringComparison.OrdinalIgnoreCase)); - // Defensive fallback for a hand-edited file where a character - // row was added with a name but no id yet. + // Normalize BOTH sides through TryParse/ToHexString rather + // than a raw string compare (Campaign LA plan §LA3 review + // finding F10): a stored id that round-trips to the same + // uint (different case, or — before this fix — no "0x" + // prefix) must match even though its text isn't byte- + // identical to the canonical form this method itself always + // writes. + CharacterProfile? existing = profile.Characters.Find( + character => CharacterIdFormat.TryParse(character.Id, out uint existingId) + && existingId == entry.Id); + + // Defensive fallback for a row whose id is missing OR + // unparseable (e.g. a hand-edited id with no "0x" prefix, + // which TryParse now rejects outright) — match by name + // instead so a later merge self-heals the id into the + // canonical form rather than creating a permanent duplicate + // row. existing ??= profile.Characters.Find( - character => character.Id is null + character => !CharacterIdFormat.TryParse(character.Id, out _) && string.Equals( character.Name, entry.Name, diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index b216392b..b4471e36 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -26,7 +26,7 @@ public sealed record ConnectedStatusEvent : StatusEvent; public readonly record struct StatusCharacterEntry( uint Id, string Name, - int SecondsGreyedOut); + uint SecondsGreyedOut); public sealed record CharacterListStatusEvent : StatusEvent { @@ -79,3 +79,18 @@ public sealed record UnknownStatusEvent : StatusEvent { public required string RawJson { get; init; } } + +/// +/// A status line whose e value IS one of the recognized event +/// names, but whose payload does not match that event's expected shape +/// (a missing required field, or a field present with the wrong JSON +/// kind). Distinguished from (Campaign +/// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older +/// host sent an event I've never heard of" apart from "a host I recognize +/// sent me garbage for an event I do know" — the two cases call for +/// different diagnostics. The tailer never throws for either case. +/// +public sealed record MalformedStatusEvent : StatusEvent +{ + public required string Error { get; init; } +} diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index 17495b7c..112ddfe5 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -11,23 +11,40 @@ namespace AcDream.Launcher.Core.Status; /// "accountName":"...","slotCount":6,"characters":[...]}. /// /// -/// Never throws: a line whose e is not one of the eight known -/// values, or whose payload doesn't match that event's expected shape, -/// or that isn't valid JSON at all, degrades to a typed -/// rather than an exception — a -/// launcher must keep tailing a session's status stream even against a -/// host running a newer/older wire version. +/// Never throws: a null/blank/malformed-JSON line, an unrecognized +/// e value, or a recognized e whose payload doesn't match +/// that event's expected shape, all degrade to a typed event +/// ( or +/// — see each type's docs) rather than an exception — a launcher must +/// keep tailing a session's status stream even against a host running a +/// newer/older wire version, or a host that briefly writes a torn line. /// /// public static class StatusEventParser { public static StatusEvent Parse(string line) { - ArgumentException.ThrowIfNullOrWhiteSpace(line); + if (string.IsNullOrWhiteSpace(line)) + { + // Campaign LA plan §LA3 review finding F7: a blank/whitespace + // line is a normal "nothing complete here yet" degrade, not a + // caller error — the old ArgumentException.ThrowIfNullOrWhiteSpace + // guard ran BEFORE the try/catch below and escaped uncaught. + return UnknownEvent(line ?? string.Empty); + } + JsonDocument document; try { - using JsonDocument document = JsonDocument.Parse(line); + document = JsonDocument.Parse(line); + } + catch (JsonException) + { + return UnknownEvent(line); + } + + using (document) + { JsonElement root = document.RootElement; int v = GetInt32OrDefault(root, "v"); @@ -35,51 +52,70 @@ public static class StatusEventParser DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t"); string sessionId = GetStringOrDefault(root, "sessionId"); - return e switch + try { - "started" => - new StartedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, - "connected" => - new ConnectedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, - "characterList" => - ParseCharacterList(root, v, e, t, sessionId), - "enteredWorld" => - ParseEnteredWorld(root, v, e, t, sessionId), - "pluginLoaded" => - ParsePluginLoaded(root, v, e, t, sessionId), - "pluginFailed" => - ParsePluginFailed(root, v, e, t, sessionId), - "disconnected" => - ParseDisconnected(root, v, e, t, sessionId), - "exited" => - ParseExited(root, v, e, t, sessionId), - _ => - new UnknownStatusEvent - { - V = v, - E = e, - T = t, - SessionId = sessionId, - RawJson = line, - }, - }; - } - catch (Exception) - { - // JsonException (malformed JSON), FormatException (a - // required-field miss inside a Parse* helper) — all degrade - // the same way: never throw out of the tailer. - return new UnknownStatusEvent + return e switch + { + "started" => + new StartedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "connected" => + new ConnectedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "characterList" => + ParseCharacterList(root, v, e, t, sessionId), + "enteredWorld" => + ParseEnteredWorld(root, v, e, t, sessionId), + "pluginLoaded" => + ParsePluginLoaded(root, v, e, t, sessionId), + "pluginFailed" => + ParsePluginFailed(root, v, e, t, sessionId), + "disconnected" => + ParseDisconnected(root, v, e, t, sessionId), + "exited" => + ParseExited(root, v, e, t, sessionId), + _ => + new UnknownStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + RawJson = line, + }, + }; + } + catch (Exception ex) when (ex is FormatException or InvalidOperationException) { - V = 0, - E = string.Empty, - T = default, - SessionId = string.Empty, - RawJson = line, - }; + // FormatException: a Require* helper found a missing + // field or a field of the wrong JSON kind (e.g. + // "secondsGreyedOut": true"). InvalidOperationException: + // a JsonElement API call (EnumerateArray, TryGetProperty) + // against an element of the wrong ValueKind (e.g. + // "characters" present but not an array). Both mean `e` + // WAS recognized but its payload wasn't — distinguished + // from UnknownStatusEvent (Campaign LA plan §LA3 review + // finding F12). + return new MalformedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Error = ex.Message, + }; + } } } + private static UnknownStatusEvent UnknownEvent(string rawLine) => + new() + { + V = 0, + E = string.Empty, + T = default, + SessionId = string.Empty, + RawJson = rawLine, + }; + private static StatusEvent ParseCharacterList( JsonElement root, int v, @@ -96,7 +132,7 @@ public static class StatusEventParser { uint id = RequireUInt32(item, "id"); string name = RequireString(item, "name"); - int secondsGreyedOut = RequireInt32(item, "secondsGreyedOut"); + uint secondsGreyedOut = RequireUInt32(item, "secondsGreyedOut"); characters.Add(new StatusCharacterEntry(id, name, secondsGreyedOut)); } diff --git a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs index 69b81852..a37b4075 100644 --- a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs +++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs @@ -33,10 +33,31 @@ public sealed class StatusFileTailer /// /// Reads and parses every complete line appended to the file since /// the last call. Returns an empty list (never null, never throws) - /// when the file doesn't exist yet or nothing new/complete has - /// arrived since the last poll. + /// when the file doesn't exist yet, has been deleted/rotated between + /// the existence check and the open (a TOCTOU window — Campaign LA + /// plan §LA3 review finding F7), or nothing new/complete has arrived + /// since the last poll. /// public IReadOnlyList ReadNewEvents() + { + try + { + return ReadNewEventsCore(); + } + catch (Exception ex) when ( + ex is FileNotFoundException or DirectoryNotFoundException or IOException) + { + // The host process deleted/rotated the file (or its + // directory) between File.Exists and the open below, or + // another transient I/O condition hit mid-read — degrade to + // "nothing new this poll" rather than throwing out of a + // method documented never to throw; the next poll picks up + // wherever the file (or its replacement) actually is. + return []; + } + } + + private IReadOnlyList ReadNewEventsCore() { if (!File.Exists(_path)) { diff --git a/tests/AcDream.Launcher.Core.Tests/LauncherCoreDependencyBoundaryTests.cs b/tests/AcDream.Launcher.Core.Tests/LauncherCoreDependencyBoundaryTests.cs new file mode 100644 index 00000000..8009b02c --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/LauncherCoreDependencyBoundaryTests.cs @@ -0,0 +1,79 @@ +using System.Runtime.CompilerServices; +using System.Xml.Linq; + +namespace AcDream.Launcher.Core.Tests; + +// Campaign LA plan §LA3 review finding F5: AcDream.Launcher.Core's entire +// premise (spec §LA3, SessionConfigDocument.cs's "PINNED CONTRACT" remarks) +// is being the BCL-plus-Platform-only assembly the external Avalonia +// launcher (LA4) can reference without pulling in any game-solution +// dependency. That contract is what this guard enforces — the csproj must +// declare exactly one ProjectReference (AcDream.Platform) and zero +// PackageReference entries, forever, in the same spirit as Platform's, +// Runtime's, and Headless's dependency-boundary guards. +public sealed class LauncherCoreDependencyBoundaryTests +{ + [Fact] + public void LauncherCoreProjectReferencesOnlyPlatformAndDeclaresNoPackages() + { + string repositoryRoot = FindRepositoryRoot(); + string projectPath = Path.Combine( + repositoryRoot, + "src", + "AcDream.Launcher.Core", + "AcDream.Launcher.Core.csproj"); + var project = XDocument.Load(projectPath); + + var projectReferences = project.Descendants("ProjectReference") + .Select(element => element.Attribute("Include")?.Value) + // The csproj is authored with Windows-style "..\Foo\Foo.csproj" + // separators; Path.GetFileName only recognizes the platform's + // own separator, so on Linux it would return the whole + // relative path unchanged instead of just the filename. + // Normalizing to '/' first keeps this assertion + // platform-agnostic (this project's tests run under both + // native Windows and WSL — see Campaign LA plan §LA3 review + // finding F5's acceptance). + .Select(include => include is null + ? null + : Path.GetFileName(include.Replace('\\', '/'))) + .ToList(); + + Assert.Equal(["AcDream.Platform.csproj"], projectReferences); + Assert.Empty(project.Descendants("PackageReference")); + } + + private static string FindRepositoryRoot( + [CallerFilePath] string sourcePath = "") + { + string[] starts = + { + Path.GetDirectoryName(sourcePath) ?? string.Empty, + Directory.GetCurrentDirectory(), + AppContext.BaseDirectory, + }; + foreach (string start in starts) + { + if (string.IsNullOrEmpty(start)) + { + continue; + } + + var directory = new DirectoryInfo(start); + while (directory is not null) + { + if (File.Exists(Path.Combine( + directory.FullName, + "AcDream.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + } + + throw new DirectoryNotFoundException( + "Could not find AcDream.slnx above the source, working, or output directory."); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index df12ea29..bab8b6c2 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -109,6 +109,132 @@ public sealed class LauncherProcessSupervisorTests Assert.Equal(0, fake.KillCallCount); } + [Fact] + public void StopAttemptsTheGracefulStopSignalBeforeCloseMainWindow() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + FakeChildProcess fake = factory.LastCreated!; + Assert.Equal(1, fake.TryRequestGracefulStopCallCount); + Assert.Equal(["gracefulStop", "closeMainWindow"], fake.CallOrder); + } + + [Fact] + public void GracefulStopSignalSendsSigintToARealChildOnLinux() + { + // Review finding F3, proven end to end against the real + // SystemChildProcess: Stop() sends SIGINT before falling back to + // CloseMainWindow/Kill, and the trapped child exits gracefully + // with code 0 well within the timeout. A hard SIGKILL fallback + // (or a signal arriving before the shell's trap is even armed, + // which falls back to the shell's default SIGINT disposition — + // terminate with exit 128+2=130) would not produce this clean + // exit code, so ExitCode == 0 is a hermetic proof the graceful + // path is what actually stopped the child. + if (!OperatingSystem.IsLinux()) + return; + + string readyMarker = Path.Combine( + Path.GetTempPath(), "acdream-la3-sigint-" + Guid.NewGuid().ToString("N")); + try + { + using var supervisor = new LauncherProcessSupervisor(); + var exited = new ManualResetEventSlim(false); + supervisor.StateChanged += (_, s) => + { + if (s == LauncherSessionState.Exited) + exited.Set(); + }; + + supervisor.Start( + new LauncherProcessSpec( + "/bin/bash", + [ + "-c", + "trap 'kill $child 2>/dev/null; exit 0' INT; " + + "sleep 30 & child=$!; " + + $"touch '{readyMarker}'; " + + "wait $child", + ]), + password: null); + + // Wait for the child to prove its SIGINT trap is armed AND + // its background `sleep` is tracked (touch runs after both, + // in program order) before sending the signal — otherwise + // this test would race the shell's own startup and + // intermittently observe the shell's default SIGINT + // disposition instead of the trap, or leave an untracked + // orphaned `sleep`. + DateTime readyDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (!File.Exists(readyMarker) && DateTime.UtcNow < readyDeadline) + { + Thread.Sleep(10); + } + + Assert.True( + File.Exists(readyMarker), + "child did not signal trap-armed readiness in time"); + + supervisor.Stop(TimeSpan.FromSeconds(10)); + + Assert.True(exited.Wait(TimeSpan.FromSeconds(5))); + Assert.Equal(0, supervisor.ExitCode); + } + finally + { + try + { + File.Delete(readyMarker); + } + catch (IOException) + { + } + } + } + + [Fact] + public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted() + { + var factory = new FakeChildProcessFactory( + exitsWithinStopTimeout: true, + throwOnStandardInputWrite: true); + using var supervisor = new LauncherProcessSupervisor(factory); + + Assert.ThrowsAny(() => supervisor.Start(Spec(), "pw")); + + FakeChildProcess fake = factory.LastCreated!; + Assert.True(fake.Started); + Assert.Equal(1, fake.KillCallCount); + Assert.True(fake.Disposed); + } + + [Fact] + public void SetStateIsMonotonicAndIgnoresATransitionAfterExited() + { + // Simulates the child exiting synchronously from inside + // process.Start() itself (a child that dies immediately) — the + // trailing SetState(Running) at the end of Start() must not + // resurrect State from the terminal Exited it already reached, + // nor fire a spurious StateChanged(Running). + var factory = new FakeChildProcessFactory( + exitsWithinStopTimeout: true, + exitDuringStart: true); + using var supervisor = new LauncherProcessSupervisor(factory); + var states = new List(); + supervisor.StateChanged += (_, s) => states.Add(s); + + supervisor.Start(Spec(), "pw"); + + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + Assert.Equal( + [LauncherSessionState.Starting, LauncherSessionState.Exited], + states); + } + [Fact] public void LauncherProcessSpecCarriesNoCredentialLikeMember() { @@ -162,22 +288,34 @@ public sealed class LauncherProcessSupervisorTests // because this test is itself running under `dotnet test`. OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; - private sealed class FakeChildProcessFactory(bool exitsWithinStopTimeout) + private sealed class FakeChildProcessFactory( + bool exitsWithinStopTimeout, + bool exitDuringStart = false, + bool throwOnStandardInputWrite = false) : ILauncherChildProcessFactory { public FakeChildProcess? LastCreated { get; private set; } public ILauncherChildProcess Create(LauncherProcessSpec spec) { - LastCreated = new FakeChildProcess(spec, exitsWithinStopTimeout); + LastCreated = new FakeChildProcess( + spec, + exitsWithinStopTimeout, + exitDuringStart, + throwOnStandardInputWrite); return LastCreated; } } - private sealed class FakeChildProcess(LauncherProcessSpec spec, bool exitsWithinStopTimeout) + private sealed class FakeChildProcess( + LauncherProcessSpec spec, + bool exitsWithinStopTimeout, + bool exitDuringStart = false, + bool throwOnStandardInputWrite = false) : ILauncherChildProcess { private readonly RecordingTextWriter _standardInput = new(); + private readonly ThrowingTextWriter _throwingStandardInput = new(); public LauncherProcessSpec Spec { get; } = spec; @@ -191,27 +329,59 @@ public sealed class LauncherProcessSupervisorTests public int CloseMainWindowCallCount { get; private set; } + public int TryRequestGracefulStopCallCount { get; private set; } + public int KillCallCount { get; private set; } + public bool Disposed { get; private set; } + + /// Records the order , + /// , and were + /// actually invoked in — review finding F3's ordering guarantee. + /// + public List CallOrder { get; } = []; + public bool HasExited { get; private set; } public int ExitCode { get; private set; } - public TextWriter StandardInput => _standardInput; + public TextWriter StandardInput => + throwOnStandardInputWrite ? _throwingStandardInput : _standardInput; public event EventHandler? Exited; - public void Start() => Started = true; + public void Start() + { + Started = true; + + if (exitDuringStart) + { + // Simulates a child that dies synchronously from inside + // Process.Start() itself (review finding F9's race). + HasExited = true; + ExitCode = 0; + Exited?.Invoke(this, EventArgs.Empty); + } + } + + public bool TryRequestGracefulStop() + { + TryRequestGracefulStopCallCount++; + CallOrder.Add("gracefulStop"); + return false; + } public bool CloseMainWindow() { CloseMainWindowCallCount++; + CallOrder.Add("closeMainWindow"); return true; } public void Kill() { KillCallCount++; + CallOrder.Add("kill"); HasExited = true; ExitCode = -1; Exited?.Invoke(this, EventArgs.Empty); @@ -230,6 +400,7 @@ public sealed class LauncherProcessSupervisorTests public void Dispose() { + Disposed = true; } } @@ -243,4 +414,16 @@ public sealed class LauncherProcessSupervisorTests base.Dispose(disposing); } } + + /// Simulates a broken stdin pipe (review finding F8): the + /// child process started successfully, but feeding it the password + /// fails. + private sealed class ThrowingTextWriter : StringWriter + { + public override void Write(string? value) => + throw new IOException("simulated broken stdin pipe"); + + public override void Write(char value) => + throw new IOException("simulated broken stdin pipe"); + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs index 49d8615b..26bd3c0a 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs @@ -135,6 +135,45 @@ public sealed class SessionConfigComposerTests Assert.Equal("+Acdream", (string?)session["character"]!["name"]); } + [Fact] + public void GuiModeFallsBackToNameSelectorWhenIdIsAHandTypedDecimalWithoutThe0xPrefix() + { + // Review finding F10: an 8-digit all-decimal-digit string is ALSO + // a syntactically valid hex number. Without requiring the "0x" + // prefix, this used to silently reinterpret a hand-typed decimal + // id as hex and select the wrong character; it must now fall + // through to the name selector instead of guessing. + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui, id: "12345678"), + Install, + Paths, + sessionId: "session-gui-decimal-id"); + + JsonObject session = SingleSession(composed); + Assert.Null(session["character"]!["id"]); + Assert.Equal("+Acdream", (string?)session["character"]!["name"]); + } + + [Fact] + public void GuiModeFallsBackToNameSelectorWhenTheParsedIdIsZero() + { + // Review finding F10: both host loaders reject `id: 0` outright, + // so a parsed-but-zero id is not a usable selector either. + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui, id: "0x00000000"), + Install, + Paths, + sessionId: "session-gui-zero-id"); + + JsonObject session = SingleSession(composed); + Assert.Null(session["character"]!["id"]); + Assert.Equal("+Acdream", (string?)session["character"]!["name"]); + } + [Fact] public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays() { @@ -156,7 +195,7 @@ public sealed class SessionConfigComposerTests } [Fact] - public void ProcessContentCarriesInstallRecordAndPathsIsAlwaysPresent() + public void ProcessContentCarriesInstallRecordAndPathsIsOmittedByDefault() { ComposedSessionConfig composed = SessionConfigComposer.Compose( Server(), @@ -169,11 +208,15 @@ public sealed class SessionConfigComposerTests JsonObject root = ParseRoot(composed); Assert.Equal(1, (int?)root["version"]); JsonObject process = root["process"]!.AsObject(); - AssertKeys(process, "paths", "content"); - // Paths is always present as an object; every member is omitted - // when unset (hosts resolve their own default ApplicationPathSet). - Assert.Empty(process["paths"]!.AsObject()); + // PINNED CONTRACT (review finding F1): process.paths is OMITTED + // entirely — not an empty object — unless a caller explicitly + // supplies overrides. The App-side loader parses with strict + // UnmappedMemberHandling.Disallow and has no `paths` member of + // its own, so an emitted "paths":{} would reject the whole + // document at config load for every gui/guiSelect launch. + AssertKeys(process, "content"); + Assert.False(process.ContainsKey("paths")); JsonObject content = process["content"]!.AsObject(); AssertKeys(content, "datDirectory", "preparedAssetPath"); @@ -181,6 +224,92 @@ public sealed class SessionConfigComposerTests Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]); } + [Fact] + public void NormalPlaySessionsOmitTheModeFieldEntirely() + { + foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless }) + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(mode), + Install, + Paths, + sessionId: $"session-mode-omit-{mode}"); + + JsonObject session = SingleSession(composed); + Assert.False(session.ContainsKey("mode")); + } + } + + [Fact] + public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands() + { + ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( + Server(), + Account(), + Install, + Paths, + sessionId: "session-probe"); + + JsonObject session = SingleSession(composed); + + AssertKeys( + session, + "id", "mode", "endpoint", "account", "credential", "statusFile"); + + Assert.Equal("session-probe", (string?)session["id"]); + Assert.Equal("probe", (string?)session["mode"]); + Assert.Equal("127.0.0.1", (string?)session["endpoint"]!["host"]); + Assert.Equal(9000, (int?)session["endpoint"]!["port"]); + Assert.Equal("testaccount", (string?)session["account"]); + Assert.Equal("standardInput", (string?)session["credential"]!["provider"]); + Assert.False(session.ContainsKey("character")); + Assert.False(session.ContainsKey("policy")); + Assert.False(session.ContainsKey("plugins")); + Assert.False(session.ContainsKey("loginCommands")); + Assert.False(session.ContainsKey("loginCommandDelayMs")); + Assert.Equal( + Path.Combine( + Paths.CacheDirectory, "launcher", "sessions", "session-probe", "status.jsonl"), + (string?)session["statusFile"]); + } + + [Fact] + public void ProbeModeDocumentNeverContainsThePassword() + { + AccountProfile account = Account(); + + ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( + Server(), + account, + Install, + Paths, + sessionId: "session-probe-pw"); + + string json = SessionConfigComposer.Serialize(composed.Document); + Assert.DoesNotContain(account.Password, json, StringComparison.Ordinal); + } + + [Fact] + public void ProbeModeProcessSettingsMatchNormalComposition() + { + ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( + Server(), + Account(), + Install, + Paths, + sessionId: "session-probe-content"); + + JsonObject root = ParseRoot(composed); + JsonObject process = root["process"]!.AsObject(); + AssertKeys(process, "content"); + + JsonObject content = process["content"]!.AsObject(); + Assert.Equal(Install.DatDirectory, (string?)content["datDirectory"]); + Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]); + } + [Fact] public void ComposedDocumentNeverContainsThePassword() { diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs index fdade48b..089e0ea7 100644 --- a/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs @@ -14,8 +14,8 @@ public sealed class CharacterIdFormatTests [Theory] [InlineData("0x5000000A", 0x5000000Au)] [InlineData("0x5000000a", 0x5000000Au)] - [InlineData("5000000A", 0x5000000Au)] - public void TryParseAcceptsWithAndWithoutPrefixAndCase(string text, uint expected) + [InlineData("0X5000000A", 0x5000000Au)] + public void TryParseAcceptsThe0xPrefixCaseInsensitively(string text, uint expected) { Assert.True(CharacterIdFormat.TryParse(text, out uint id)); Assert.Equal(expected, id); @@ -26,8 +26,15 @@ public sealed class CharacterIdFormatTests [InlineData("")] [InlineData(" ")] [InlineData("not-hex")] - public void TryParseRejectsNullEmptyOrNonHex(string? text) + [InlineData("5000000A")] + [InlineData("12345678")] + public void TryParseRejectsNullEmptyNonHexOrAnUnprefixedString(string? text) { + // "5000000A"/"12345678" are all-hex-digit strings that would + // parse fine as hex WITHOUT the "0x" prefix — review finding F10 + // requires the prefix precisely so a hand-typed decimal id (which + // is ALSO syntactically valid hex) is never silently + // misinterpreted as one. Assert.False(CharacterIdFormat.TryParse(text, out uint id)); Assert.Equal(0u, id); } diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs index db6a672a..957fa2cb 100644 --- a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs @@ -1,3 +1,4 @@ +using System.Threading; using AcDream.Launcher.Core.Profiles; namespace AcDream.Launcher.Core.Tests.Profiles; @@ -284,4 +285,105 @@ public sealed class LauncherProfileStoreTests : IDisposable UnixFileMode.UserRead | UnixFileMode.UserWrite, mode); } + + [Fact] + public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite() + { + // Review finding F4: the temp file used to be created with the + // process's default umask and only chmod'd AFTER the atomic + // rename, leaving a window where the plaintext-credential temp + // file could be world/group-readable. The fix chmods the temp + // file immediately after creation, BEFORE any content (including + // the password) is serialized into it. A large document makes + // the write take long enough for a concurrent poller to have a + // real chance at observing a regression. + if (!OperatingSystem.IsLinux()) + return; + + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + for (int i = 0; i < 300; i++) + { + store.AddAccount("Local ACE", $"account{i}", new string('x', 4096)); + } + + string tempPath = _filePath + ".tmp"; + bool observedLooseMode = false; + bool stop = false; + var poller = new Thread(() => + { + while (!Volatile.Read(ref stop)) + { + if (File.Exists(tempPath)) + { + try + { + // The platform-compat analyzer can't see the + // enclosing test method's `OperatingSystem.IsLinux()` + // guard across this lambda boundary; suppressed + // rather than restructured, since the guard is + // real and this whole method is a no-op off Linux. +#pragma warning disable CA1416 + UnixFileMode mode = File.GetUnixFileMode(tempPath); +#pragma warning restore CA1416 + if ((mode & ~(UnixFileMode.UserRead | UnixFileMode.UserWrite)) != 0) + { + observedLooseMode = true; + } + } + catch (IOException) + { + // Renamed/deleted between the Exists check and + // GetUnixFileMode — not a finding, just keep + // polling. + } + } + } + }); + poller.Start(); + + store.Save(); + + Volatile.Write(ref stop, true); + poller.Join(); + + Assert.False(observedLooseMode); + } + + [Fact] + public void SaveDeletesTheStaleTempFileWhenTheFinalRenameFails() + { + // Review finding F4: force the rename step to fail (the + // destination path names an existing DIRECTORY, which + // File.Move(..., overwrite: true) refuses to replace — Windows + // reports this as UnauthorizedAccessException, Linux as + // IOException, so the assertion below accepts either) and assert + // the temp file — which still carries the just-serialized + // plaintext credentials — doesn't linger on disk afterward. + Directory.CreateDirectory(_filePath); + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "testpassword"); + + Assert.ThrowsAny(() => store.Save()); + + Assert.False(File.Exists(_filePath + ".tmp")); + } + + [Fact] + public void LoadDeletesAStaleTempFileLeftBehindByACrashedSave() + { + // Review finding F4: a Save() that crashed between creating the + // temp file and the atomic rename leaves a ".tmp" carrying the + // same plaintext credentials as the real store. Load() cleans it + // up opportunistically the next time the store is opened. + File.WriteAllText(_filePath + ".tmp", """{"version":1,"servers":[]}"""); + + var store = new LauncherProfileStore(_filePath); + store.Load(); + + Assert.False(File.Exists(_filePath + ".tmp")); + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs index 9b967ea9..c151be08 100644 --- a/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs @@ -126,6 +126,32 @@ public sealed class RosterMergeTests Assert.Contains(characters, c => c.Name == "+PendingDelete"); } + [Fact] + public void MergeNormalizesAnUnprefixedHexIdInsteadOfCreatingADuplicateRow() + { + // Review finding F10: a hand-edited row can carry an id without + // the "0x" prefix (e.g. copy-pasted from somewhere that dropped + // it). CharacterIdFormat.TryParse now REJECTS that string + // outright (it no longer guesses hex-without-a-prefix), so the + // old raw string-equality comparison against the roster's + // canonical "0x..." form would never match and would add a + // second row forever. The name-fallback match must still + // recognize this as the SAME character and self-heal its id. + LauncherProfileStore store = NewStoreWithServerAndAccount(); + store.Document.Servers.Single().Accounts.Single().Characters.Add( + new CharacterProfile { Id = "5000000A", Name = "+Acdream" }); + + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + + CharacterProfile character = Assert.Single( + store.Document.Servers.Single().Accounts.Single().Characters); + Assert.Equal("0x5000000A", character.Id); + Assert.Equal("+Acdream", character.Name); + } + [Fact] public void MergeThrowsForUnknownServerOrAccount() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index ef98becd..d28b642b 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -46,9 +46,9 @@ public sealed class StatusEventParserTests Assert.Equal(2, list.Characters.Count); Assert.Equal(1342177290u, list.Characters[0].Id); Assert.Equal("+Acdream", list.Characters[0].Name); - Assert.Equal(0, list.Characters[0].SecondsGreyedOut); + Assert.Equal(0u, list.Characters[0].SecondsGreyedOut); Assert.Equal(1342177291u, list.Characters[1].Id); - Assert.Equal(1, list.Characters[1].SecondsGreyedOut); + Assert.Equal(1u, list.Characters[1].SecondsGreyedOut); } [Fact] @@ -112,14 +112,59 @@ public sealed class StatusEventParserTests Assert.IsType(e); } - [Fact] - public void KnownEValueWithMissingRequiredFieldSurfacesAsUnknownEventRatherThanThrowing() + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void WhitespaceOrEmptyLineSurfacesAsUnknownEventRatherThanThrowing(string line) { - // characterList without "characters" — a shape mismatch, not - // just an unrecognized e value. - var e = StatusEventParser.Parse( - """{"v":1,"e":"characterList","t":"2026-08-14T12:00:09Z","sessionId":"s1","accountName":"a","slotCount":6}"""); + // Review finding F7: ArgumentException.ThrowIfNullOrWhiteSpace + // used to guard this method BEFORE the try/catch, so a + // whitespace-only line (e.g. a stray blank line the tailer + // happens to hand over) escaped as an uncaught exception instead + // of degrading like every other malformed-input case. + var e = StatusEventParser.Parse(line); Assert.IsType(e); } + + [Fact] + public void NullLineSurfacesAsUnknownEventRatherThanThrowing() + { + var e = StatusEventParser.Parse(null!); + + Assert.IsType(e); + } + + [Fact] + public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing() + { + // characterList without "characters" — a shape mismatch on a + // KNOWN event name. Review finding F12: this must be + // distinguishable from an unrecognized e value, so it now + // surfaces as MalformedStatusEvent rather than UnknownStatusEvent. + var e = StatusEventParser.Parse( + """{"v":1,"e":"characterList","t":"2026-08-14T12:00:09Z","sessionId":"s1","accountName":"a","slotCount":6}"""); + + var malformed = Assert.IsType(e); + Assert.Equal("characterList", malformed.E); + Assert.Equal("s1", malformed.SessionId); + Assert.False(string.IsNullOrWhiteSpace(malformed.Error)); + } + + [Fact] + public void KnownEValueWithAFieldOfTheWrongJsonKindSurfacesAsMalformedEvent() + { + // "characters" present but not an array — this throws + // InvalidOperationException out of JsonElement.EnumerateArray() + // rather than the FormatException a missing/wrong-kind scalar + // field throws, so it exercises the parser's other malformed- + // payload catch path. + var e = StatusEventParser.Parse( + """{"v":1,"e":"characterList","t":"2026-08-14T12:00:10Z","sessionId":"s1","accountName":"a","slotCount":6,"characters":"not-an-array"}"""); + + var malformed = Assert.IsType(e); + Assert.Equal("characterList", malformed.E); + Assert.False(string.IsNullOrWhiteSpace(malformed.Error)); + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs index f908ec06..b619fe25 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -141,6 +141,33 @@ public sealed class StatusFileTailerTests : IDisposable Assert.IsType(secondPoll[0]); } + [Fact] + public void ReadNewEventsReturnsEmptyRatherThanThrowingOnASharingViolation() + { + // A deterministic proxy for the File.Exists -> new FileStream + // TOCTOU window (review finding F7): Windows enforces FileShare + // at the OS level, so holding an exclusive (FileShare.None) + // handle open while the tailer tries to open the same path + // reliably reproduces the IOException the tailer must now + // swallow instead of throwing out of a method documented never + // to throw. (.NET's FileStream doesn't apply mandatory locking + // on Linux by default, so this specific scenario isn't + // reproducible there — the fix itself is platform-agnostic, only + // this particular deterministic trigger is Windows-only.) + if (!OperatingSystem.IsWindows()) + return; + + AppendShared(Line("started", "s1")); + using var exclusiveHandle = new FileStream( + _path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Empty(events); + } + [Fact] public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced() { From c9fc7f4a66b33388397aab27a55754d83a87a2c8 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:30:38 +0200 Subject: [PATCH 017/138] =?UTF-8?q?docs:=20Campaign=20LA=20session=20hando?= =?UTF-8?q?ff=20=E2=80=94=20state,=20in-flight=20recovery,=20process,=20go?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained handoff for a fresh session: what the campaign is and which decisions are settled, the slice ledger with commits, the in-flight slices and how to recover them from git, the two owed merge items (cross-assembly contract test, Launcher.Core CI lane), the session landmines (index-sweep, stale agent worktrees, contract-in-prompts), the binding process, and the goal text to set. Committed via pathspec so a live implementer agent index in this worktree is untouched. Co-Authored-By: Claude Fable 5 --- .../2026-08-14-campaign-la-handoff.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/research/2026-08-14-campaign-la-handoff.md diff --git a/docs/research/2026-08-14-campaign-la-handoff.md b/docs/research/2026-08-14-campaign-la-handoff.md new file mode 100644 index 00000000..cd7dd77c --- /dev/null +++ b/docs/research/2026-08-14-campaign-la-handoff.md @@ -0,0 +1,202 @@ +# Campaign LA — session handoff (2026-08-14) + +**Read this first, then `docs/plans/2026-08-14-launcher-campaign.md` (the plan + +ledger), then `docs/superpowers/specs/2026-08-14-launcher-campaign-design.md` +(the approved design).** Memory crib: +`claude-memory/project_launcher_direction.md`. + +Branch: `claude/acdream-launcher-credentials-4d2f7c` +Worktree: `.claude/worktrees/acdream-launcher-credentials-4d2f7c` +HEAD at handoff: `498f1c11` + +--- + +## 1. What Campaign LA is + +One external product — the **acdream launcher** — that is simultaneously the +installer, the updater, and the multi-server / multi-account / multi-character +session launcher (ThwargLauncher UX model), on **Windows and Linux**; plus the +one client-side feature the launcher flow exposes as missing, the **retail +character-management screen**. + +Design decisions already made and NOT to be re-litigated (spec §2): + +- Avalonia UI; `AcDream.Launcher` (thin) + `AcDream.Launcher.Core` (BCL-only). +- **Credentials in a plaintext file — user-decided.** 0600 on Linux; never in + logs, arguments, session configs, or the status stream. +- **Approach A, file-contract orchestrator:** the launcher speaks NO game + protocol. Config file in → password via child stdin → JSONL status events + out. (Launcher embedding Runtime was REJECTED: a probe login that fails to + tear down gracefully poisons the ACE account ~3 min.) +- Full CRUD in the launcher UI; hand-editing JSON is never required. +- Character enumeration by **headless probe** (connect → CharacterList → + graceful disconnect BEFORE EnterWorld → exit) plus cache-from-observation. +- **Retail char-select has NO 3D preview** — recon-corrected. Retail's + `gmCharacterManagementUI` is a flat listbox + Enter/Delete/Restore + dialogs; + the rotating-model viewport is character-CREATION-only. Create Character is a + future campaign. +- Everything (launch + install + update) in ONE campaign. +- **Linux posture (user-directed):** the full launcher stack ships Linux-tested + in this campaign; GUI *client* launches stay Windows-only until Slice L + resumes later. The launcher renders gui/guiSelect disabled on Linux with an + explicit Slice-L note. + +--- + +## 2. Slice ledger at handoff + +| Slice | State | Commits | +|---|---|---| +| LA0 `AcDream.Platform` extraction | **DONE** (review closed) | `cb6502c8`, `a49e92df`, `7a839cba` | +| LA1 launch contract (App CLI + status writer + roster seam) | implemented; **Opus review returned FIX-FIRST**; fix round IN FLIGHT | `db9ad53c` (MIXED — see §4), note `e1322a06` | +| LA2 probe mode + idle policy | implementer IN FLIGHT | branch `campaign-la2` (base `498f1c11`) | +| LA3 `AcDream.Launcher.Core` | implemented; review FIX-FIRST (12 findings); **fix round LANDED — all 12 fixed, 94/94 Windows + WSL**; owes narrow re-review, then merge | `37d74e44`, `26feba81` on branch `campaign-la3` | +| LA7a character wire messages | **DONE + MERGED** | `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4` | +| LA4 Avalonia UI | not started (needs LA3) | — | +| LA5 plugin hosting | not started (needs LA1) | — | +| LA6 login commands | not started (needs LA1, LA5) | — | +| LA7b char-select state + flow | not started (needs LA1) | — | +| LA8 authored char-select screen | not started (needs LA7b) | — | +| LA9 installer / LA10 updater / LA11 closeout | not started | — | + +Register: **AD-97** filed (guid-only CharacterRestore request is an adaptation — +retail sends ≥16 bytes, we send 8; ACE ignores the tail). + +--- + +## 3. Work IN FLIGHT at handoff — recover these first + +Three agents were running when this handoff was written. Their results arrive +as task notifications in the ORIGINAL session only; a new session must verify +state from git instead of waiting. + +1. **LA1 fix round** — main worktree, branch + `claude/acdream-launcher-credentials-4d2f7c`. Findings: F1 (HIGH, required) + the `SessionStatusWriter` must never throw into the login/teardown + transactions and must create its parent directory (an unwritable/missing + status path currently fails a healthy session — first-run trigger); + F2 App reader must TOLERATE `process.paths` (parse-and-ignore, like the + existing `policy`) and explicitly REFUSE `mode: "probe"` with a named error; + F4 production-shape the shared fixture (`process.content`, `standardInput` + credential); F3 reconnect emits `disconnected` first + record the mid-play + drop limitation; F5–F8 minor hardening. +2. **LA2 implementer** — worktree `.claude/worktrees/acdream-la2`, branch + `campaign-la2`. +3. **LA3 fix round — COMPLETE at `26feba81`** (worktree + `.claude/worktrees/acdream-la3`, branch `campaign-la3`). All 12 findings + fixed: the CRITICAL `"paths": {}` emission (now omitted entirely), probe + composition (`ComposeProbe` + `mode` field), graceful stop (Linux SIGINT via + `libc kill`, Windows gap filed as **ISSUES #397** with the + CREATE_NEW_PROCESS_GROUP + CTRL_BREAK direction), 0600 temp-file window, + the Launcher.Core dependency-boundary guard, non-throwing parser/tailer, + monotonic supervisor state, `0x`-prefix id parsing, uint + `SecondsGreyedOut`, and `MalformedStatusEvent`. 94/94 Windows AND WSL. + **NEXT: narrow re-review of `26feba81`, then merge `campaign-la3`** (with + the two owed merge items below). + +**To recover:** `git -C log --oneline -3` and `git status` per +branch. If a fix round committed, run its narrow re-review; if it did not, +re-dispatch it from the finding list above (the reviews' full text is in the +original session transcript, but the finding summaries here are sufficient to +re-derive the work). + +**Owed at merge time (do not lose these):** +- **Cross-assembly contract test** when LA1+LA3 meet: feed an + `AcDream.Launcher.Core` composer-produced document to BOTH host loaders + (App + Headless) and assert it parses. This is the permanent anti-drift + enforcement for the pinned contract. +- **CI lane**: add `tests/AcDream.Launcher.Core.Tests` to + `.github/workflows/headless-portability.yml` (both `paths:` filters + the + Linux test array), mirroring what LA0 did for `AcDream.Platform.Tests`. +- After LA2 lands, App's reader must refuse `mode: "probe"` (covered by LA1 + fix-round F2 — verify it actually landed). + +--- + +## 4. Landmines / lessons from this session + +1. **Never run git state commands in a worktree while an implementer agent is + live in it.** `git add ` scopes the ADD; `git commit` commits the whole + INDEX. A docs commit swept 37 in-progress LA1 files into `db9ad53c`; the + marker commit `e1322a06` documents it. Memory: + `claude-memory/feedback_no_commits_beside_live_agents.md`. +2. **Auto-created agent worktrees can be based on stale history.** The first LA3 + dispatch landed on a spell-bar-era commit. Always create the worktree + yourself from the campaign HEAD and make the agent verify its base commit as + its first action. +3. **PowerShell 5.1 mangles double quotes inside heredoc commit messages** — + keep git commit bodies quote-free. +4. **The pinned contract must live on disk, not in agent prompts.** It now does + (plan §"Pinned launch-contract schema (v1, BINDING)"). The LA3 CRITICAL was + a direct consequence of it living only in prompts. +5. **Reviews have caught something tests could not, four slices running:** lost + Linux CI lanes (LA0), a real-but-mislabeled wire deviation (LA7a → AD-97), a + cross-worktree contract break (LA3), an observability sink that could fail + the transaction it observes (LA1). Do not downgrade the review step. + +--- + +## 5. How we work (binding process) + +- **Fable plans, sequences, integrates. Sonnet implements bounded slices. Opus + reviews every slice boundary, dual-lens:** (a) architectural — ownership, + layering, dependency-guard integrity, seams; (b) retail fidelity against + `docs/research/named-retail/` wherever the slice touches retail behavior. + Findings → fix round → NARROW re-review of the fixes → slice DONE in ledger. +- Max 3–4 agents in parallel INCLUDING children; subagents never spawn + subagents. Every implementer prompt carries: spec+plan paths, files to read + first, the pinned contract text if relevant, acceptance criteria, commit + style, and a base-commit verification as its first action. +- One implementer per worktree; that agent owns the worktree's git index. +- `dotnet build` + `dotnet test` green before a slice is DONE; ≥1 commit per + slice tagged `Campaign LA`; every retail deviation adds its + `docs/architecture/retail-divergence-register.md` row in the same commit; + **no workarounds without explicit user approval**. +- Linux: every slice touching Launcher.Core/Headless/Runtime/Bake/Platform runs + its test projects under WSL or native Ubuntu before it is DONE. +- The ONLY stop-and-wait is a user connected/visual gate. Everything else is + Claude's call — never present the user a work-order menu. +- Keep the plan ledger, `docs/plans/2026-04-11-roadmap.md`, the CLAUDE.md + Current-state pointer, and `claude-memory/` current as slices land. + +--- + +## 6. The goal to set + +Set this with `/goal` in the new session (it is the same directive this session +ran under, refreshed for the current state): + +```text +GOAL: Ship Campaign LA — the acdream launcher/installer/updater + retail character-select screen. + +Start at docs/research/2026-08-14-campaign-la-handoff.md, then the ledger in +docs/plans/2026-08-14-launcher-campaign.md. Recover the three in-flight slices +first (LA1 fix round on the campaign branch, LA2 on campaign-la2, LA3 fix round +on campaign-la3) by inspecting git state, then continue slice by slice. + +Process, per slice: +1. Fable plans/sequences/integrates — never present work-order menus; pick and announce. +2. Sonnet subagents implement bounded slices. Each prompt carries spec+plan paths, the + exact files to read first, the pinned contract text when relevant, acceptance criteria + (build+test green), commit style, and a base-commit verification as first action. + Subagents may not spawn subagents. Hard cap 3-4 agents in parallel including children. + One implementer per worktree; that agent owns the index — the orchestrator makes no git + state changes in a worktree while an agent is live in it. +3. Every slice boundary gets an Opus dual-lens review: (a) architectural — ownership, + layering, dependency-guard integrity, seams; (b) retail fidelity against + docs/research/named-retail/ wherever the slice touches retail behavior. Fix findings, + then a narrow re-review of the fixes. +4. dotnet build + dotnet test green before any slice is declared done; Linux (WSL or + native Ubuntu) runs for every slice touching Launcher.Core/Headless/Runtime/Bake/ + Platform. One commit per slice minimum, tagged "Campaign LA". Any retail deviation adds + its divergence-register row in the same commit. No workarounds without explicit approval. +5. As slices land: update the plan ledger, docs/plans/2026-04-11-roadmap.md, the CLAUDE.md + Current state pointer, and claude-memory/. +6. The only stop-and-wait is a user connected/visual gate (launch modes vs local ACE, + character-probe round-trip, char-select visual matrix, first-run wizard, update swap). + When a slice reaches one: write the exact test script under docs/research/, announce the + gate, and keep driving any slices not blocked behind it. + +DONE = all slices code-complete, automated gates green, dual reviews closed, and the +connected-gate checklist delivered to the user in one script document. +``` From 75a6724d5b2505c27929a5d7785e731c018b3d4d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:32:52 +0200 Subject: [PATCH 018/138] =?UTF-8?q?wip:=20Campaign=20LA=20LA1=20fix=20roun?= =?UTF-8?q?d=20=E2=80=94=20INCOMPLETE,=20stopped=20mid-task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent was stopped for token budget partway through the LA1 review fix round. Landed here: F1 best-effort SessionStatusWriter, F2 App reader tolerance (paths/mode), F5 argument-parsing hardening, plus new tests. NOT DONE: F4 shared-fixture production shape (was the next step), F3 reconnect disconnected edge + recorded limitation, F6 exited idempotency/reasons, F7 structural redaction test, F8 platform-guard test + comment fix, optional RuntimeOptions PrintMembers redaction. Build/test state UNVERIFIED at this commit. Next session: finish the remaining findings, run the suites, then narrow re-review. Co-Authored-By: Claude Fable 5 --- .../SessionConfigArgumentParsing.cs | 73 +++++++++ .../Configuration/SessionConfiguration.cs | 33 +++++ .../SessionConfigurationLoader.cs | 26 ++++ src/AcDream.App/Program.cs | 51 +++---- .../Session/SessionStatusWriter.cs | 127 ++++++++++++++-- .../SessionConfigArgumentParsingTests.cs | 97 ++++++++++++ .../SessionConfigurationLoaderTests.cs | 140 ++++++++++++++++++ .../Session/SessionStatusWriterTests.cs | 123 ++++++++++++++- .../session-config-shared-fixture.json | 10 +- 9 files changed, 628 insertions(+), 52 deletions(-) create mode 100644 src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs create mode 100644 tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs create mode 100644 tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs diff --git a/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs b/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs new file mode 100644 index 00000000..96d2f43c --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs @@ -0,0 +1,73 @@ +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1 review fix (F5): extracted from Program.cs's +/// top-level-statement local functions so the trailing-flag edge case is +/// unit testable — a top-level program's local functions are compiler- +/// synthesized private members of the generated Program class with +/// no stable surface a test assembly can reach. +/// +internal static class SessionConfigArgumentParsing +{ + /// + /// Finds in and + /// returns its value. Three distinct outcomes, distinguished by + /// and the return value together: + /// + /// flag absent: = , + /// returns — the caller's env-var/positional + /// fallback stays in effect, unchanged from before this flag + /// existed. + /// flag present with a following value: + /// = , returns that value. + /// flag present but is the LAST argument, with nothing after it: + /// = , returns + /// — the caller MUST treat this as a hard error + /// (the flag was typed but its value was not), never silently fall + /// through to the flag-absent path. + /// + /// + internal static string? ExtractFlagValue( + string[] arguments, + string flag, + out bool present) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentException.ThrowIfNullOrWhiteSpace(flag); + + for (int i = 0; i < arguments.Length; i++) + { + if (!string.Equals(arguments[i], flag, StringComparison.Ordinal)) + continue; + + present = true; + return i == arguments.Length - 1 ? null : arguments[i + 1]; + } + + present = false; + return null; + } + + /// Returns with + /// and its following value (if any) removed. A trailing, valueless flag + /// is dropped on its own — this helper only strips arguments, it does + /// not decide whether a trailing flag is an error (see + /// 's present output for that). + internal static string[] WithoutFlagAndValue(string[] arguments, string flag) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentException.ThrowIfNullOrWhiteSpace(flag); + + var result = new List(arguments.Length); + for (int i = 0; i < arguments.Length; i++) + { + if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) + { + i++; // also skip the flag's value, if any + continue; + } + result.Add(arguments[i]); + } + return [.. result]; + } +} diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs index 313f75ea..2a0359be 100644 --- a/src/AcDream.App/Configuration/SessionConfiguration.cs +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -42,6 +42,26 @@ internal sealed class SessionConfiguration internal sealed class SessionProcessSettings { public SessionContentDescriptor? Content { get; init; } + + /// Campaign LA slice LA1 review fix (F2): accepted so the SAME + /// document also satisfies the Headless loader's own + /// process.paths member (HeadlessPathOverrides) — parsed + /// and ignored here, exactly like + /// and below. App has + /// no config/data/cache directory override concept of its own (those + /// come from ApplicationPathSet/env vars on this host); only the + /// Headless host consumes overrides composed under this key. + public SessionProcessPathOverrides? Paths { get; init; } +} + +/// Accepted-but-ignored mirror of Headless's +/// HeadlessPathOverrides shape — see +/// . +internal sealed class SessionProcessPathOverrides +{ + public string? ConfigDirectory { get; init; } + public string? DataDirectory { get; init; } + public string? CacheDirectory { get; init; } } internal sealed class SessionContentDescriptor @@ -75,6 +95,19 @@ internal sealed record SessionDescriptor /// App has no bot-policy concept. public SessionPolicyDescriptor? Policy { get; init; } + /// Campaign LA slice LA1 review fix (F2): pinned-contract + /// mode discriminator. ABSENT means today's ONLY App behavior — an + /// ordinary play session — so every document written before this field + /// existed keeps parsing unchanged. "probe" (LA2's connect + /// ▸ characterList ▸ graceful-disconnect flow, no EnterWorld) is + /// HEADLESS-ONLY; the App loader rejects it with an explicit message + /// naming the field rather than the caller ever seeing a raw unmapped- + /// member . Any other value + /// is a configuration error — the pinned contract defines no other + /// mode literal, so a document is either silent about mode (play) or + /// says "probe" exactly. + public string? Mode { get; init; } + [JsonRequired] public SessionCredentialDescriptor Credential { get; init; } = new(); diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs index e26aa806..086cb11e 100644 --- a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -154,5 +154,31 @@ internal static class SessionConfigurationLoader throw new SessionConfigurationException( $"Session '{session.Id}' statusFile must be a non-empty path when present."); } + + ValidateMode(session); + } + + /// + /// Campaign LA slice LA1 review fix (F2): mode is Headless-only + /// on the App host — the graphical host has no probe concept (LA2 + /// builds the probe in Headless only). An absent field is today's ONLY + /// App behavior (play); "probe" gets a specific, actionable + /// message instead of a cryptic unmapped-member JSON error; anything + /// else is a plain configuration error. + /// + private static void ValidateMode(SessionDescriptor session) + { + if (session.Mode is null) + return; + + if (string.Equals(session.Mode, "probe", StringComparison.Ordinal)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' has mode 'probe'; probe sessions " + + "are headless-only and cannot run on the graphical host."); + } + + throw new SessionConfigurationException( + $"Session '{session.Id}' has unsupported mode '{session.Mode}'."); } } diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 61ed733a..52e647be 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -38,8 +38,22 @@ Log.Information( // existing one positional dat-dir argument and every ACDREAM_* env var keep // working exactly as before when the flag is absent. See // docs/plans/2026-08-14-launcher-campaign.md LA1. -string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config"); -string[] positionalArgs = WithoutFlagAndValue(args, "--session-config"); +// +// Review fix F5 (LA1 review round): a trailing, valueless --session-config +// (the flag typed as the LAST argument, nothing after it) must be a hard +// error, never a silent fall-through to the env-var path — a launcher that +// mis-composed its argv would otherwise appear to work while quietly +// ignoring the session-config contract entirely. +string? sessionConfigFlagPath = SessionConfigArgumentParsing.ExtractFlagValue( + args, "--session-config", out bool sessionConfigFlagPresent); +if (sessionConfigFlagPath is null && sessionConfigFlagPresent) +{ + Log.Error( + "--session-config requires a value (a path to the session-config document)."); + return 2; +} +string[] positionalArgs = + SessionConfigArgumentParsing.WithoutFlagAndValue(args, "--session-config"); var datDirArg = positionalArgs.FirstOrDefault(); var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); @@ -240,34 +254,9 @@ finally return 0; -// Campaign LA slice LA1: --session-config parsing helpers. Kept -// local/minimal rather than a general-purpose CLI parser — App has exactly -// one optional flag-with-value today; the positional dat-dir argument must -// stay untouched by its presence (see the comment above the flag parse). -static string? ExtractFlagValue(string[] arguments, string flag) -{ - for (int i = 0; i < arguments.Length - 1; i++) - { - if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) - return arguments[i + 1]; - } - return null; -} - -static string[] WithoutFlagAndValue(string[] arguments, string flag) -{ - var result = new List(arguments.Length); - for (int i = 0; i < arguments.Length; i++) - { - if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) - { - i++; // also skip the flag's value - continue; - } - result.Add(arguments[i]); - } - return [.. result]; -} - +// Campaign LA slice LA1: --session-config value-presence helper. The +// flag/positional-argument extraction itself lives in +// AcDream.App.Configuration.SessionConfigArgumentParsing (review fix F5) so +// its trailing-flag edge case is unit testable. static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index b2260453..6ccb27fe 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -33,6 +33,49 @@ namespace AcDream.Runtime.Session; /// event method below takes only identifiers, names, and counts — there is no /// parameter shape that could carry a password, by construction. /// +/// +/// +/// This writer can never fail or stall the session transaction it +/// observes (Campaign LA LA1 review fix F1). Every call site sits +/// inside a caller-owned try block that treats a throw as a real failure — +/// LiveSessionController.StartCore's connect/roster/enter-world +/// sequence, SessionStartCompositionPhase.Start (which calls +/// BEFORE Session.Start even runs), +/// GameWindow.CompleteShutdown (which calls +/// BEFORE PublishShutdownRoots, so a throw would skip graceful +/// teardown entirely), and HeadlessSessionHost.Dispose's stage machine +/// (a throw from stage 8's call leaves +/// _disposeStage unadvanced and _disposed unset forever — a +/// permanently un-disposable host). An observability sink that can fail the +/// transaction it is merely reporting on is a defect in the sink, not a +/// reason for every call site to defend itself — so every exception this +/// class's own I/O can raise (a missing parent directory on a fresh cache +/// dir, a path segment that collides with an existing file, a permissions +/// error, a network path some future caller supplies) is caught here, logged +/// once to stderr, and LATCHES the writer into a permanent no-op — the exact +/// same "cheap null-check forever after" shape a never-configured path +/// already gets. The parent directory is created lazily, once, on the first +/// write, inside the same protection, so a fresh +/// .../launcher/sessions/<id>/status.jsonl path (whose directory +/// does not exist yet) is the expected first-run case, not a failure. +/// +/// +/// +/// Latency posture: every write is a synchronous local-disk +/// file open + line append + flush + close on the calling thread — there is +/// no batching, no background writer, no async path. This is fine for the +/// low-frequency lifecycle events this class carries (at most a handful per +/// second even under LA5/LA6 plugin/login-command load) against a local +/// disk. A statusFile path that resolves to a network location (a +/// UNC share, a mapped network drive, a FUSE mount with high per-syscall +/// latency) is UNSUPPORTED BY DESIGN — every event write would block the +/// session transaction's calling thread for the round-trip, and a slow or +/// wedged network path would eventually get caught by the same catch clause +/// that handles a missing directory and latch off, silently dropping the +/// rest of that session's status stream. Callers that need a status stream +/// over the network should tail the local file with a separate process, +/// never point statusFile at a network path directly. +/// /// public sealed class SessionStatusWriter { @@ -46,6 +89,8 @@ public sealed class SessionStatusWriter private readonly string? _path; private readonly TimeProvider _timeProvider; private readonly object _gate = new(); + private bool _directoryEnsured; + private bool _latchedOff; public SessionStatusWriter(string? path, TimeProvider? timeProvider = null) { @@ -54,12 +99,13 @@ public sealed class SessionStatusWriter } /// - /// True when this writer has a configured path and will actually append - /// events. Lets a caller with an expensive report to build (e.g. the - /// roster projection) skip that work entirely when nobody configured a - /// status file for this session. + /// True when this writer has a configured path and has not latched + /// itself off after a failed write. Lets a caller with an expensive + /// report to build (e.g. the roster projection) skip that work entirely + /// when nobody configured a status file for this session, or when this + /// writer already gave up after an I/O failure. /// - public bool IsEnabled => _path is not null; + public bool IsEnabled => _path is not null && !_latchedOff; public void Started(string sessionId) => Write(new @@ -143,20 +189,71 @@ public sealed class SessionStatusWriter private void Write(T value) { - if (_path is not { } path) + if (_path is not { } path || _latchedOff) return; - string line = JsonSerializer.Serialize(value, JsonOptions); lock (_gate) { - using FileStream stream = new( - path, - FileMode.Append, - FileAccess.Write, - FileShare.Read); - using var writer = new StreamWriter(stream); - writer.WriteLine(line); - writer.Flush(); + // Re-check inside the lock: another thread may have latched the + // writer off (or already ensured the directory) between the + // fast check above and taking the gate. + if (_latchedOff) + return; + + try + { + EnsureDirectory(path); + string line = JsonSerializer.Serialize(value, JsonOptions); + using FileStream stream = new( + path, + FileMode.Append, + FileAccess.Write, + FileShare.Read); + using var writer = new StreamWriter(stream); + writer.WriteLine(line); + writer.Flush(); + } + catch (Exception error) when (IsRecoverableIoFailure(error)) + { + LatchOff(path, error); + } } } + + private void EnsureDirectory(string path) + { + if (_directoryEnsured) + return; + + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + _directoryEnsured = true; + } + + private void LatchOff(string path, Exception error) + { + _latchedOff = true; + Console.Error.WriteLine( + $"[status-writer] disabling status stream at '{path}' after a " + + $"write failure ({error.GetType().Name}: {error.Message}); no " + + "further events for this session will be written."); + } + + /// + /// The set of exceptions this class's own file I/O can plausibly raise + /// — a missing parent directory, a path segment colliding with an + /// existing file, permission failures, an unsupported path shape, or a + /// platform security restriction. Anything outside this set (e.g. an + /// ) is deliberately NOT caught — + /// this class only promises to survive ITS OWN recoverable I/O + /// failures, never to become a blanket exception sink. + /// + private static bool IsRecoverableIoFailure(Exception error) => + error is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException + or System.Security.SecurityException + or DirectoryNotFoundException; } diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs new file mode 100644 index 00000000..1aed83f0 --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigArgumentParsingTests.cs @@ -0,0 +1,97 @@ +using AcDream.App.Configuration; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1 review fix (F5): pins +/// 's trailing-flag edge case — +/// --session-config present as the LAST argument with nothing after +/// it must be distinguishable from the flag being entirely absent, so +/// Program.cs can turn it into a hard error instead of a silent +/// fall-through to the env-var/positional dat-dir path. +/// +public sealed class SessionConfigArgumentParsingTests +{ + private const string Flag = "--session-config"; + + [Fact] + public void FlagWithAFollowingValueReturnsThatValueAndIsPresent() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + ["D:\\dats", Flag, "session.json"], + Flag, + out bool present); + + Assert.True(present); + Assert.Equal("session.json", value); + } + + [Fact] + public void FlagAbsentReturnsNullAndIsNotPresent() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + ["D:\\dats"], + Flag, + out bool present); + + Assert.False(present); + Assert.Null(value); + } + + [Fact] + public void TrailingFlagWithNoValueIsPresentWithANullValue() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + ["D:\\dats", Flag], + Flag, + out bool present); + + // This is the case Program.cs must turn into exit code 2 — present + // but no value is categorically different from "not present at + // all", even though both currently yield a null return value. + Assert.True(present); + Assert.Null(value); + } + + [Fact] + public void FlagAloneAsTheOnlyArgumentIsPresentWithANullValue() + { + string? value = SessionConfigArgumentParsing.ExtractFlagValue( + [Flag], + Flag, + out bool present); + + Assert.True(present); + Assert.Null(value); + } + + [Fact] + public void WithoutFlagAndValueDropsTheFlagAndItsValue() + { + string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue( + ["D:\\dats", Flag, "session.json", "extra"], + Flag); + + Assert.Equal(["D:\\dats", "extra"], positional); + } + + [Fact] + public void WithoutFlagAndValueTrailingFlagDropsOnlyTheFlagItself() + { + string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue( + ["D:\\dats", Flag], + Flag); + + Assert.Equal(["D:\\dats"], positional); + } + + [Fact] + public void WithoutFlagAndValueLeavesArgumentsUnchangedWhenFlagIsAbsent() + { + string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue( + ["D:\\dats"], + Flag); + + Assert.Equal(["D:\\dats"], positional); + } +} diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs new file mode 100644 index 00000000..54e64408 --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationLoaderTests.cs @@ -0,0 +1,140 @@ +using AcDream.App.Configuration; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1 review fix (F2): the App session-config reader +/// must TOLERATE the two document shapes only the Headless side of the +/// pinned contract currently defines meaning for — process.paths +/// (HeadlessPathOverrides) and the per-session mode +/// discriminator (LA2's probe flow) — so a launcher-composed document does +/// not throw a raw unmapped-member +/// on the App host. See docs/plans/2026-08-14-launcher-campaign.md +/// LA1's pinned contract. +/// +public sealed class SessionConfigurationLoaderTests +{ + [Fact] + public void ProcessPathsAreAcceptedButIgnored() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "process": { + "paths": { + "configDirectory": "/config", + "dataDirectory": "/data", + "cacheDirectory": "/cache" + } + }, + "sessions": [ + { + "id": "paths-tolerant", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" } + } + ] + } + """); + + (SessionConfiguration configuration, SessionDescriptor session) = + SessionConfigurationLoader.Load(file.Path); + + Assert.Equal("paths-tolerant", session.Id); + Assert.Equal("/config", configuration.Process?.Paths?.ConfigDirectory); + Assert.Equal("/data", configuration.Process?.Paths?.DataDirectory); + Assert.Equal("/cache", configuration.Process?.Paths?.CacheDirectory); + } + + [Fact] + public void AbsentModeIsTreatedAsPlay() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "no-mode", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" } + } + ] + } + """); + + (_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path); + + Assert.Null(session.Mode); + } + + [Fact] + public void ProbeModeFailsLoadWithAnExplicitHeadlessOnlyMessage() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-session", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" }, + "mode": "probe" + } + ] + } + """); + + SessionConfigurationException error = Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + Assert.Contains("mode", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("probe", error.Message, StringComparison.Ordinal); + Assert.Contains("headless-only", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnrecognizedModeFailsLoad() + { + using TemporaryFile file = TemporaryFile.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-mode", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "credential": { "provider": "environment", "reference": "X" }, + "mode": "bogus" + } + ] + } + """); + + Assert.Throws( + () => SessionConfigurationLoader.Load(file.Path)); + } + + private sealed class TemporaryFile : IDisposable + { + private TemporaryFile(string path) => Path = path; + + internal string Path { get; } + + internal static TemporaryFile Create(string json) + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-app-la1-loader-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + return new TemporaryFile(path); + } + + public void Dispose() => File.Delete(Path); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index 5b9e7133..c1bfc020 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -97,8 +97,22 @@ public sealed class SessionStatusWriterTests writer.Started("s1"); } + /// + /// F7 (Campaign LA LA1 review fix round): replaces the earlier + /// "DoesNotContain 'hunter2'/'password'" assertion, which could never + /// actually fail — no writer method below accepts a credential-shaped + /// parameter in the first place, so the absence of those literal strings + /// proved nothing about the SHAPE of what gets serialized. This test + /// asserts the structural claim that actually backs the "never write + /// credential material into this stream" contract: each event kind + /// serializes EXACTLY its pinned property set — the shared envelope + /// (v/e/t/sessionId) plus that event's own + /// named fields, nothing else. An extra property (a smuggled password, + /// or any other accidental field) fails this test by construction, + /// regardless of what value it carries. + /// [Fact] - public void PasswordNeverAppearsInTheStatusStream() + public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse() { using TemporaryFile file = TemporaryFile.Create(); var writer = new SessionStatusWriter(file.Path); @@ -115,9 +129,110 @@ public sealed class SessionStatusWriterTests writer.Disconnected("bot", "stopped"); writer.Exited("bot", 0, "disposed"); - string contents = File.ReadAllText(file.Path); - Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal); - Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase); + string[] lines = File.ReadAllLines(file.Path); + Assert.Equal(6, lines.Length); + + AssertExactProperties(lines[0], "v", "e", "t", "sessionId"); + AssertExactProperties(lines[1], "v", "e", "t", "sessionId"); + AssertExactProperties( + lines[2], + "v", "e", "t", "sessionId", "accountName", "slotCount", "characters"); + AssertExactProperties( + lines[3], "v", "e", "t", "sessionId", "characterId", "characterName"); + AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason"); + AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason"); + + // The nested characters[] entries are exact too — the exact shape a + // password could otherwise be smuggled through. + JsonElement character = Parse(lines[2]).GetProperty("characters")[0]; + AssertExactProperties(character, "id", "name", "secondsGreyedOut"); + } + + private static void AssertExactProperties(string line, params string[] expected) => + AssertExactProperties(Parse(line), expected); + + private static void AssertExactProperties(JsonElement element, params string[] expected) + { + string[] actual = element.EnumerateObject() + .Select(static property => property.Name) + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + string[] sortedExpected = expected + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(sortedExpected, actual); + } + + /// + /// F1 (Campaign LA LA1 review fix round): a status file whose parent + /// directory does not exist yet — the expected first-run shape of + /// .../launcher/sessions/<id>/status.jsonl on a fresh cache + /// dir — must be created lazily rather than throwing + /// out of the transaction the + /// writer is merely observing. + /// + [Fact] + public void MissingParentDirectoryIsCreatedAndEventsFlow() + { + string root = Path.Combine( + Path.GetTempPath(), + $"acdream-status-root-{Guid.NewGuid():N}"); + string path = Path.Combine(root, "nested", "sessions", "s1", "status.jsonl"); + try + { + Assert.False(Directory.Exists(Path.GetDirectoryName(path))); + var writer = new SessionStatusWriter(path); + + writer.Started("s1"); + writer.Connected("s1"); + + Assert.True(writer.IsEnabled); + string[] lines = File.ReadAllLines(path); + Assert.Equal(2, lines.Length); + Assert.Contains("\"started\"", lines[0]); + Assert.Contains("\"connected\"", lines[1]); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + /// + /// F1: a path whose PARENT SEGMENT already exists as an ordinary file + /// (so cannot turn it into a + /// directory) is exactly the "unwritable path" case the review asked + /// for — the writer must latch itself off instead of throwing, and every + /// subsequent call must stay a cheap no-op. + /// + [Fact] + public void ParentSegmentIsAFileLatchesTheWriterInsteadOfThrowing() + { + string blocker = Path.Combine( + Path.GetTempPath(), + $"acdream-status-blocker-{Guid.NewGuid():N}"); + File.WriteAllText(blocker, "not a directory"); + string path = Path.Combine(blocker, "status.jsonl"); + try + { + var writer = new SessionStatusWriter(path); + Assert.True(writer.IsEnabled); + + // Must not throw — the writer swallows its own I/O failure and + // latches off instead of failing the caller's transaction. + writer.Started("s1"); + Assert.False(writer.IsEnabled); + + // Latched-off calls stay cheap no-ops — no exception, no retry. + writer.Connected("s1"); + writer.Exited("s1", 0, "disposed"); + } + finally + { + if (File.Exists(blocker)) + File.Delete(blocker); + } } [Fact] diff --git a/tests/Fixtures/campaign-la/session-config-shared-fixture.json b/tests/Fixtures/campaign-la/session-config-shared-fixture.json index 822921a6..24ec55a1 100644 --- a/tests/Fixtures/campaign-la/session-config-shared-fixture.json +++ b/tests/Fixtures/campaign-la/session-config-shared-fixture.json @@ -1,5 +1,11 @@ { "version": 1, + "process": { + "content": { + "datDirectory": "shared-fixture-dats", + "preparedAssetPath": "shared-fixture-dats/acdream.pak" + } + }, "sessions": [ { "id": "shared-fixture", @@ -8,8 +14,8 @@ "character": { "name": "SharedToon" }, "policy": { "id": "idle" }, "credential": { - "provider": "environment", - "reference": "SHARED_FIXTURE_PASSWORD" + "provider": "standardInput", + "reference": "session" }, "plugins": ["ExamplePlugin", "AnotherPlugin"], "loginCommands": ["/tell someone, hi", "/vt start"], From c6019424675201b5c2ebbb7429d40c93d071bb03 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:33:00 +0200 Subject: [PATCH 019/138] =?UTF-8?q?wip:=20Campaign=20LA=20LA2=20probe=20mo?= =?UTF-8?q?de=20+=20idle=20policy=20=E2=80=94=20INCOMPLETE,=20stopped=20mi?= =?UTF-8?q?d-task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent was stopped for token budget. Landed here: probe flag through LiveSessionConnectOptions + the StartCore short-circuit, the mode field with JsonRequired-to-semantic-validation move, host exit-code mapping, and 34 passing tests including 3 new probe tests (agent last reported green before the stop). NOT DONE: the idle-policy unit tests (next step), full-suite verification, and the WSL run. Build/test state UNVERIFIED at this commit. Next session: finish idle policy tests, run Runtime+Headless Release suites Windows and WSL, then dispatch the Opus dual-lens review. Co-Authored-By: Claude Fable 5 --- .../Configuration/HeadlessConfiguration.cs | 43 +++- .../HeadlessConfigurationLoader.cs | 61 +++++- .../Hosting/HeadlessProcessHost.cs | 16 ++ .../Hosting/HeadlessSessionHost.cs | 60 ++++-- .../Policies/HeadlessBotPolicy.cs | 79 +++++++ src/AcDream.Runtime/GameRuntimeCommands.cs | 8 + .../Session/LiveSessionContracts.cs | 18 +- .../Session/LiveSessionController.cs | 26 +++ .../Session/LiveSessionHost.cs | 2 + .../HeadlessConfigurationLoaderTests.cs | 202 ++++++++++++++++++ .../HeadlessEntryPointTests.cs | 70 ++++++ .../HeadlessSessionHostTests.cs | 195 +++++++++++++++++ .../SessionConfigurationSharedFixtureTests.cs | 4 +- .../Session/LiveSessionControllerTests.cs | 81 ++++++- 14 files changed, 830 insertions(+), 35 deletions(-) diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 42fe7e67..fbdbdbe9 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -46,11 +46,33 @@ internal sealed record HeadlessSessionDescriptor [JsonRequired] public string Account { get; init; } = string.Empty; - [JsonRequired] - public HeadlessCharacterSelector Character { get; init; } = new(); + /// + /// Campaign LA slice LA2: ABSENT () for normal play + /// sessions; for the LA2 probe + /// (connect → characterList → graceful disconnect, never EnterWorld) — + /// the pinned launch-contract schema's mode field + /// (docs/plans/2026-08-14-launcher-campaign.md LA1/LA2). + /// / requiredness depends on + /// this value, which is why their requiredness lives in + /// 's semantic validation rather + /// than a [JsonRequired] attribute — that attribute fires during + /// deserialization, before can be inspected at all. + /// + public HeadlessSessionMode? Mode { get; init; } - [JsonRequired] - public HeadlessBotPolicyDescriptor Policy { get; init; } = new(); + /// + /// Required for play sessions ( absent); MUST be + /// omitted for probe sessions () — + /// the pinned contract keeps the shape unambiguous by forbidding a probe + /// session from also declaring a selector. Enforced by + /// , not + /// [JsonRequired] (see this record's own doc on ). + /// + public HeadlessCharacterSelector? Character { get; init; } + + /// Same mode-dependent requiredness as : + /// required for play sessions, forbidden for probe sessions. + public HeadlessBotPolicyDescriptor? Policy { get; init; } [JsonRequired] public HeadlessCredentialReference Credential { get; init; } = new(); @@ -140,6 +162,19 @@ internal sealed class HeadlessBotPolicyDescriptor public HeadlessBotPolicyRole? Role { get; init; } } +/// +/// Campaign LA slice LA2: see . +/// The pinned launch-contract schema defines exactly two states for a +/// session — ABSENT (mapped to , meaning "play") or +/// the literal string "probe" — so is the only +/// member; there is no explicit "play" spelling. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum HeadlessSessionMode +{ + Probe, +} + /// See . [JsonConverter(typeof(JsonStringEnumConverter))] internal enum HeadlessBotPolicyRole diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 016b79c2..4c83699c 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -182,6 +182,57 @@ internal static class HeadlessConfigurationLoader $"Session '{session.Id}' requires a non-empty account."); } + ValidateModeShape(session); + + if (session.Credential is null + || string.IsNullOrWhiteSpace(session.Credential.Reference)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a credential reference."); + } + + ValidateCharacterOptions(session); + ValidateLaunchContractFields(session); + } + + /// + /// Campaign LA slice LA2: mode-dependent requiredness for + /// / + /// — this REPLACES the + /// former `[JsonRequired]` attributes on both properties (which fired + /// unconditionally at deserialize time, before a probe session's + /// omission could ever be distinguished from a play session's mistake). + /// A play session (mode absent) keeps EXACTLY today's strictness: a + /// missing/malformed character selector or a missing policy id still + /// fails load, just via + /// naming the field instead of a raw citing + /// "missing required properties" — same exit code (3, + /// HeadlessExitCode.ConfigurationError) either way, more specific + /// text now (an accepted improvement, not a contract change). A probe + /// session (mode "probe") must OMIT both fields entirely — the pinned + /// contract keeps the shape unambiguous by rejecting a probe session + /// that also declares a selector or a policy, rather than silently + /// ignoring them. + /// + private static void ValidateModeShape(HeadlessSessionDescriptor session) + { + if (session.Mode == HeadlessSessionMode.Probe) + { + if (session.Character is not null) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'character' — a probe never selects a character."); + } + if (session.Policy is not null) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'policy' — a probe never drives a bot policy."); + } + return; + } + if (session.Character is null) { throw new HeadlessConfigurationException( @@ -206,16 +257,6 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Session '{session.Id}' requires a non-empty policy id."); } - - if (session.Credential is null - || string.IsNullOrWhiteSpace(session.Credential.Reference)) - { - throw new HeadlessConfigurationException( - $"Session '{session.Id}' requires a credential reference."); - } - - ValidateCharacterOptions(session); - ValidateLaunchContractFields(session); } /// diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 4d4d1b83..5d38edb0 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -200,6 +200,22 @@ internal sealed class HeadlessProcessHost : IDisposable foreach (HeadlessSessionHost session in _sessions) { RuntimeSessionStartResult started = session.Start(); + // Campaign LA slice LA2: ProbeComplete is a SUCCESS variant, not + // a connection failure — the session already connected, reported + // its roster, and gracefully disconnected before EnterWorld (see + // LiveSessionController's probe short-circuit). Continue to the + // next configured session instead of returning ConnectionError, + // so a probe session sharing a process with play sessions never + // tears the others down. ProbeHeadlessBotPolicy already reports + // IsComplete, so the scheduler below skips this session entirely. + if (started.Status == RuntimeSessionStartStatus.ProbeComplete) + { + _diagnostics.Lifecycle( + session.SessionId, + "probed", + session.Runtime); + continue; + } if (started.Status != RuntimeSessionStartStatus.Connected) { if (started.Error is { } error) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index ea3a56d4..0a17e8f5 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -319,7 +319,7 @@ internal sealed class HeadlessSessionHost : IDisposable // doc). Gating on role keeps two sessions // writing the SAME field from ever racing — // only one role ever writes it. - if (descriptor.Policy.Role + if (descriptor.Policy?.Role == HeadlessBotPolicyRole.Recruit && gateCoordinator is not null) { @@ -366,13 +366,24 @@ internal sealed class HeadlessSessionHost : IDisposable hostLease = runtime.AcquireHostLease( $"headless:{descriptor.Id}"); + // Campaign LA slice LA2: a probe session's descriptor carries no + // `policy` at all (the loader rejects the opposite pairing) — a + // probe never reaches TrySelectCharacter/EnterWorld, so there is + // no policy id to switch on. ProbeHeadlessBotPolicy reports + // IsComplete unconditionally so HeadlessProcessScheduler treats + // this session as already finished the instant it is + // constructed, letting the scheduler's Run() loop return + // immediately for a probe-only process instead of waiting for + // SIGINT. policy = policyOverride - ?? HeadlessBotPolicyFactory.Create( - descriptor.Policy, - runtime, - () => _pendingConfirmation, - RespondToConfirmation, - gateCoordinator); + ?? (descriptor.Mode == HeadlessSessionMode.Probe + ? new ProbeHeadlessBotPolicy() + : HeadlessBotPolicyFactory.Create( + descriptor.Policy!, + runtime, + () => _pendingConfirmation, + RespondToConfirmation, + gateCoordinator)); policySubscription = runtime.Subscribe(policy); diagnostics.Lifecycle( descriptor.Id, @@ -636,11 +647,18 @@ internal sealed class HeadlessSessionHost : IDisposable _stoppedGeneration); // Campaign LA slice LA1: "exited" = terminal — the sole // point every disposal path (graceful and post- - // quarantine) converges on. + // quarantine) converges on. LA2: a probe session that + // never faulted reports reason "probe" here instead of + // "disposed" — the pinned contract's exit event for a + // successful probe. _statusWriter.Exited( _descriptor.Id, _faulted ? 1 : 0, - _faulted ? "fault" : "disposed"); + _faulted + ? "fault" + : _descriptor.Mode == HeadlessSessionMode.Probe + ? "probe" + : "disposed"); _disposeStage++; _disposed = true; break; @@ -717,7 +735,8 @@ internal sealed class HeadlessSessionHost : IDisposable _descriptor.Endpoint.Port, _descriptor.Account, password, - MapCharacterSelector(_descriptor.Character)); + MapCharacterSelector(_descriptor.Character), + Probe: _descriptor.Mode == HeadlessSessionMode.Probe); LiveSessionStartResult result = _liveSession.Start(options); if (result.Selection is { } selection) _accountName = selection.AccountName; @@ -964,12 +983,19 @@ internal sealed class HeadlessSessionHost : IDisposable return declared; } - private static LiveSessionCharacterSelector MapCharacterSelector( - HeadlessCharacterSelector selector) => - new( - selector.Index, - selector.Id, - selector.Name); + /// Campaign LA slice LA2: for a probe + /// session (the loader guarantees Character is omitted whenever + /// Mode is ) — a probe + /// never reaches TrySelectCharacter, so "no selector configured" + /// is the correct, harmless mapping. + private static LiveSessionCharacterSelector? MapCharacterSelector( + HeadlessCharacterSelector? selector) => + selector is null + ? null + : new( + selector.Index, + selector.Id, + selector.Name); private RuntimeSessionStartResult Convert( LiveSessionStartResult result) @@ -988,6 +1014,8 @@ internal sealed class HeadlessSessionHost : IDisposable RuntimeSessionStartStatus.Deferred, LiveSessionStartStatus.Failed => RuntimeSessionStartStatus.Failed, + LiveSessionStartStatus.ProbeComplete => + RuntimeSessionStartStatus.ProbeComplete, _ => throw new ArgumentOutOfRangeException( nameof(result), result.Status, diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs index 90ee240c..f35c16a4 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -100,6 +100,21 @@ internal static class HeadlessBotPolicyFactory } } +/// +/// Campaign LA slice LA2: the "idle" consumer policy id — the session enters +/// world (unchanged start/select/EnterWorld +/// path) and then does nothing actively: no chat, no movement, no combat. +/// is permanently , so +/// keeps ticking the session +/// (harmlessly — and every delta handler below are no-ops) +/// until the process is stopped (SIGINT/cancellation) or disposed; teardown +/// then rides 's existing graceful +/// stop/logout path — the same mechanism K4's endurance gate already proved. +/// No is required. This class +/// predates LA2 (introduced at K1 as dev/test scaffolding); LA2 formalizes it +/// as the documented headless "just sit in world" play policy and adds +/// focused coverage in HeadlessBotPolicyTests. +/// internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy { public bool IsComplete => false; @@ -149,6 +164,70 @@ internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy } } +/// +/// Campaign LA slice LA2: the policy substituted (never selected via +/// — a probe session's +/// descriptor carries no policy id at all) for a +/// session. +/// is from construction, BEFORE +/// even runs, so +/// never dispatches a tick to this +/// session — a probe session's +/// is already gracefully torn down by +/// 's probe +/// short-circuit by the time the scheduler would otherwise look at it, and a +/// single-session probe process's Run() loop returns immediately +/// instead of waiting for SIGINT. +/// +internal sealed class ProbeHeadlessBotPolicy : IHeadlessBotPolicy +{ + public bool IsComplete => true; + + public void Tick( + IGameRuntimeView view, + IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + public void Dispose() + { + } +} + /// /// Explicit connected-gate policy: wait for the local player, issue one /// harmless local-speech command and one lifestone recall, reconnect after diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index f20db0f2..db177ed1 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -27,6 +27,14 @@ public enum RuntimeSessionStartStatus Failed, Inactive, StaleGeneration, + /// + /// Campaign LA slice LA2: mirrors + /// — a probe + /// session connected, reported its roster, and gracefully disconnected + /// before EnterWorld. A SUCCESS outcome for the headless process host's + /// exit-code mapping, not a failure. + /// + ProbeComplete, } public readonly record struct RuntimeSessionStartResult( diff --git a/src/AcDream.Runtime/Session/LiveSessionContracts.cs b/src/AcDream.Runtime/Session/LiveSessionContracts.cs index 49ca27d2..e8f81d4b 100644 --- a/src/AcDream.Runtime/Session/LiveSessionContracts.cs +++ b/src/AcDream.Runtime/Session/LiveSessionContracts.cs @@ -13,7 +13,23 @@ public sealed record LiveSessionConnectOptions( int Port, string User, string Password, - LiveSessionCharacterSelector? Character = null); + LiveSessionCharacterSelector? Character = null, + /// + /// Campaign LA slice LA2: short-circuits + /// 's connect transaction right after + /// the roster report (before TrySelectCharacter/ + /// ApplySelectedCharacter/EnterWorld) — connect, receive + /// CharacterList, report the roster, gracefully disconnect via the + /// same StopCore teardown the + /// path already uses, and return + /// . The pinned launch + /// contract (docs/plans/2026-08-14-launcher-campaign.md LA1's + /// mode field) requires a probe session to omit both + /// and its policy entirely, but the controller + /// itself does not enforce that pairing — the headless config loader + /// does, before a is ever built. + /// + bool Probe = false); public interface IRuntimeLiveSessionFramePhase { diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 1ecd5d00..ee4fdfbb 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -13,6 +13,14 @@ public enum LiveSessionStartStatus Connected, Deferred, Failed, + /// + /// Campaign LA slice LA2: a + /// session connected, received (and reported) the character roster, and + /// gracefully disconnected BEFORE selection/EnterWorld — deliberately a + /// SUCCESS variant of the early-exit shape + /// (same StopCore teardown), not a failure. + /// + ProbeComplete, } public readonly record struct LiveSessionOwnershipSnapshot( @@ -647,6 +655,24 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } + // Campaign LA slice LA2: the probe short-circuit lands here — + // right after the roster report, before TrySelectCharacter ever + // runs — so a probe session never reaches selection, + // ApplySelectedCharacter, or EnterWorld. This mirrors the + // NoCharacters early-exit immediately below (same StopCore + // teardown), the deliberate difference being the returned status + // is a SUCCESS, not a failure. Non-probe callers (options.Probe + // is false by default) fall straight through to the unchanged + // selection/enter path below — byte-identical to pre-LA2 + // behavior. + if (options.Probe) + { + Console.WriteLine( + "live: probe complete — disconnecting before EnterWorld"); + StopCore(); + return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete); + } + if (characters is null || !TrySelectCharacter( characters, diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 95e8f553..5ceb04e2 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -316,6 +316,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands RuntimeSessionStartStatus.Deferred, LiveSessionStartStatus.Failed => RuntimeSessionStartStatus.Failed, + LiveSessionStartStatus.ProbeComplete => + RuntimeSessionStartStatus.ProbeComplete, _ => throw new ArgumentOutOfRangeException( nameof(result), result.Status, diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs index 56d16c33..9c21273c 100644 --- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -208,6 +208,208 @@ public sealed class HeadlessConfigurationLoaderTests Assert.Empty(declared!); } + // ── Campaign LA slice LA2: probe-mode `mode` field shape validation ── + + [Fact] + public void ProbeSessionOmittingCharacterAndPolicyLoads() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-session", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": "probe", + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(file.Path); + + HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!; + Assert.Null(session.Character); + Assert.Null(session.Policy); + } + + [Fact] + public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-with-character", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": "probe", + "character": { "index": 0 }, + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains("probe", exception.Message, StringComparison.Ordinal); + Assert.Contains("character", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ProbeSessionDeclaringPolicyFailsLoadNamingTheField() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-with-policy", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": "probe", + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains("probe", exception.Message, StringComparison.Ordinal); + Assert.Contains("policy", exception.Message, StringComparison.Ordinal); + } + + /// + /// A play session (mode absent) missing `character` must still fail — + /// the LA2 change moved this requiredness from `[JsonRequired]` (a raw + /// at deserialize time) to + /// 's semantic + /// check (a naming the + /// missing field). Exit-code parity (both map to + /// HeadlessExitCode.ConfigurationError) is proven at + /// HeadlessEntryPointTests; this test pins the loader-level + /// exception type/message. + /// + [Fact] + public void PlaySessionMissingCharacterStillFailsLoad() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "play-missing-character", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "PLAY_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + "requires a character selector", + exception.Message, + StringComparison.Ordinal); + } + + /// Same parity claim as + /// for the + /// `policy` field. + [Fact] + public void PlaySessionMissingPolicyStillFailsLoad() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "play-missing-policy", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "credential": { "provider": "environment", "reference": "PLAY_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + "requires a non-empty policy id", + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void PlaySessionKeepsTodaysStrictCharacterSelectorAndPolicyValidation() + { + // Unrelated to `mode` — proves the LA2 refactor of ValidateSession + // did not loosen the existing selector-shape/policy-id checks for + // ordinary play sessions (mode absent). + using TemporaryConfiguration badSelector = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-selector", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0, "name": "Two" }, + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "A" } + } + ] + } + """); + using TemporaryConfiguration blankPolicy = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "blank-policy", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "policy": { "id": "" }, + "credential": { "provider": "environment", "reference": "B" } + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(badSelector.Path)); + Assert.Throws( + () => HeadlessConfigurationLoader.Load(blankPolicy.Path)); + } + private static string ConfigurationWith(params string[] sessions) => $$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}"""; diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs index 02ffca6e..41dcdf16 100644 --- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs @@ -191,6 +191,76 @@ public sealed class HeadlessEntryPointTests Assert.Contains(expected, error.ToString()); } + /// + /// Campaign LA slice LA2: before this change, an omitted `character` or + /// `policy` field failed deserialization itself with a raw + /// ("missing required + /// properties") — this test pins that the LA2 move to semantic + /// validation ( + /// naming the exact missing field) preserves the SAME exit code + /// (3, HeadlessExitCode.ConfigurationError) end to end through + /// . + /// The message text change (generic → field-naming) is a deliberate, + /// accepted improvement, not a contract break. + /// + [Theory] + [InlineData( + """ + {"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","policy":{"id":"idle"},"credential":{"provider":"environment","reference":"X"}}]} + """, + "character selector")] + [InlineData( + """ + {"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","character":{"index":0},"credential":{"provider":"environment","reference":"X"}}]} + """, + "policy id")] + public void PlaySessionMissingCharacterOrPolicyKeepsConfigurationErrorExitCode( + string json, + string expectedMessageFragment) + { + using var file = TemporaryConfiguration.Create(json); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal((int)HeadlessExitCode.ConfigurationError, exitCode); + Assert.Contains( + expectedMessageFragment, + error.ToString(), + StringComparison.OrdinalIgnoreCase); + Assert.Equal(string.Empty, output.ToString()); + } + + /// + /// Campaign LA slice LA2: a valid probe-mode session (mode "probe", + /// character/policy both omitted) passes `validate` — the launcher's + /// "refresh characters" flow only needs the process to accept the + /// document, not to run it. + /// + [Fact] + public void ValidateAcceptsProbeSessionOmittingCharacterAndPolicy() + { + using var file = TemporaryConfiguration.Create( + """ + {"version":1,"sessions":[{"id":"probe","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","mode":"probe","credential":{"provider":"environment","reference":"X"}}]} + """); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal((int)HeadlessExitCode.Success, exitCode); + Assert.Contains("1 session(s)", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + [Fact] public void UnknownCommandReturnsUsageError() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 5c9871f5..332a9933 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -151,6 +151,170 @@ public sealed class HeadlessSessionHostTests // writer is a permanent no-op with no configured path. } + /// + /// Campaign LA slice LA2: a probe-mode session's status stream reports + /// started/connected/characterList and then converges straight to + /// exited(reason:"probe", code:0) — never enteredWorld — and the + /// underlying operations fake proves EnterWorld was literally never + /// called (not merely that no wire message happened to arrive). + /// + [Fact] + public void ProbeSessionEmitsRosterThenExitsSuccessfullyWithoutEnteringWorld() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-probe-status-{Guid.NewGuid():N}.jsonl"); + try + { + var operations = new FixtureSessionOperations(); + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + ProbeDescriptor(statusFile: statusPath), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + RuntimeSessionStartResult started = host.Start(); + Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, started.Status); + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.False(host.Runtime.Session.IsInWorld); + + host.Dispose(); + + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + + string[] lines = File.ReadAllLines(statusPath); + string[] eventNames = lines + .Select(line => JsonDocument.Parse(line) + .RootElement.GetProperty("e").GetString()!) + .ToArray(); + Assert.DoesNotContain("enteredWorld", eventNames); + Assert.Contains("characterList", eventNames); + Assert.Contains("exited", eventNames); + Assert.True( + Array.IndexOf(eventNames, "characterList") + < Array.IndexOf(eventNames, "exited"), + "characterList must land before the terminal exited event."); + + using JsonDocument exitedDoc = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "exited")]); + Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32()); + Assert.Equal( + "probe", + exitedDoc.RootElement.GetProperty("reason").GetString()); + + string contents = File.ReadAllText(statusPath); + Assert.DoesNotContain("password", contents, StringComparison.Ordinal); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + + /// + /// Campaign LA slice LA2: + /// maps a ProbeComplete start to + /// (0) rather than — a + /// single-session probe-only process must exit cleanly and promptly + /// without ever needing SIGINT/cancellation, because + /// ProbeHeadlessBotPolicy reports IsComplete immediately. + /// + [Fact] + public async Task ProcessHostMapsProbeCompleteStartToSuccessExitCode() + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + ProbeDescriptor( + provider: HeadlessCredentialProviderKind.StandardInput, + credentialReference: "probe-password"), + ], + }; + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides()); + using var diagnostics = new StringWriter(); + var operations = new FixtureSessionOperations(); + using var host = new HeadlessProcessHost( + configuration, + paths, + new System.IO.StringReader("probe-password" + Environment.NewLine), + diagnostics, + operations); + // Deliberately NOT cancelled — a probe-only process must return on + // its own; a hang here would mean the scheduler never recognized + // the probe session as already complete. + using var cancellation = new CancellationTokenSource( + TimeSpan.FromSeconds(10)); + + HeadlessExitCode result = await host.RunAsync(cancellation.Token); + + Assert.Equal(HeadlessExitCode.Success, result); + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.False(cancellation.IsCancellationRequested); + } + + /// + /// Campaign LA slice LA2: a probe session completing must not tear down + /// a sibling play session sharing the same process — the process exit + /// code is 0 only once every configured session has succeeded (the + /// probe counts as success the instant it completes; the play session + /// keeps running until cancellation). + /// + [Fact] + public async Task ProbeSessionSharingAProcessDoesNotTearDownASiblingPlaySession() + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + ProbeDescriptor( + "probe-sibling", + provider: HeadlessCredentialProviderKind.StandardInput, + credentialReference: "probe-password"), + Descriptor( + HeadlessCredentialProviderKind.StandardInput, + "play-password"), + ], + }; + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides()); + using var diagnostics = new StringWriter(); + var operations = new FixtureSessionOperations(); + using var host = new HeadlessProcessHost( + configuration, + paths, + new System.IO.StringReader( + "probe-password" + Environment.NewLine + + "play-password" + Environment.NewLine), + diagnostics, + operations); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + HeadlessExitCode result = await host.RunAsync(cancellation.Token); + + Assert.Equal(HeadlessExitCode.Success, result); + Assert.Equal(2, host.Sessions.Count); + HeadlessSessionHost probeSession = Assert.Single( + host.Sessions, + s => s.SessionId == "probe-sibling"); + HeadlessSessionHost playSession = Assert.Single( + host.Sessions, + s => s.SessionId == "bot"); + Assert.False(probeSession.Runtime.Session.IsInWorld); + Assert.True(playSession.Runtime.Session.IsInWorld); + Assert.False(playSession.IsFaulted); + } + [Fact] public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() { @@ -2086,6 +2250,32 @@ public sealed class HeadlessSessionHostTests StatusFile = statusFile, }; + /// Campaign LA slice LA2: a probe-mode descriptor — mode + /// "probe", Character/Policy both omitted per the pinned + /// contract shape enforces. + private static HeadlessSessionDescriptor ProbeDescriptor( + string id = "probe-bot", + HeadlessCredentialProviderKind provider = + HeadlessCredentialProviderKind.Environment, + string credentialReference = "PROBE_PASSWORD", + string? statusFile = null) => new() + { + Id = id, + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Mode = HeadlessSessionMode.Probe, + Credential = new HeadlessCredentialReference + { + Provider = provider, + Reference = credentialReference, + }, + StatusFile = statusFile, + }; + private static void HydrateGroundedPlayer(GameRuntime runtime) { const uint player = 0x50000002u; @@ -2773,10 +2963,15 @@ public sealed class HeadlessSessionHostTests true, true); + /// Campaign LA slice LA2: lets a probe test assert the + /// live-session controller never reached EnterWorld. + public int EnterWorldCallCount { get; private set; } + public void EnterWorld( WorldSession session, int activeCharacterIndex) { + EnterWorldCallCount++; } public void Tick(WorldSession session) diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs index 631a6c1d..3db166b6 100644 --- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -28,8 +28,8 @@ public sealed class SessionConfigurationSharedFixtureTests Assert.Equal("127.0.0.1", session.Endpoint.Host); Assert.Equal(9000, session.Endpoint.Port); Assert.Equal("sharedaccount", session.Account); - Assert.Equal("SharedToon", session.Character.Name); - Assert.Equal("idle", session.Policy.Id); + Assert.Equal("SharedToon", session.Character!.Name); + Assert.Equal("idle", session.Policy!.Id); Assert.Equal( HeadlessCredentialProviderKind.Environment, session.Credential.Provider); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 6a3f49bf..b4ff6a25 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -389,6 +389,81 @@ public sealed class LiveSessionControllerTests Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); } + /// + /// Campaign LA slice LA2: the probe short-circuit — connect, receive + /// CharacterList, report the roster, then gracefully disconnect via the + /// SAME StopCore teardown + /// exercises, returning + /// instead of ever reaching TrySelectCharacter/ApplySelectedCharacter/ + /// EnterWorld. Asserted directly against the operations fake: + /// stays zero and "enter:*"/ + /// "selected"/"activate"/"entered" never appear in the call trace. + /// + [Fact] + public void Start_ProbeReportsRosterThenGracefullyDisconnectsWithoutSelectionOrEnterWorld() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start( + LiveOptions(probe: true), + host); + + Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status); + Assert.Null(result.Selection); + Assert.Equal( + [ + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "roster", "deactivate", + "detach-events", "dispose-session", "detach-session", "reset", + ], + calls); + Assert.DoesNotContain("selected", calls); + Assert.DoesNotContain("activate", calls); + Assert.DoesNotContain("entered", calls); + Assert.Equal(0, operations.EnterWorldCount); + LiveSessionRosterReport roster = Assert.Single(host.Rosters); + Assert.Equal("Canonical", roster.AccountName); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + + // The same 4-stage graceful teardown the NoCharacters path uses — + // the probe's scope fully converges without requiring + // controller.Dispose(). + LiveSessionOwnershipSnapshot ownership = controller.CaptureOwnership(); + Assert.Equal(RuntimeTeardownStage.Complete, ownership.LastTeardownStages); + Assert.False(ownership.HasActiveSession); + Assert.False(ownership.HasRetiredSession); + Assert.False(ownership.HasPendingOperation); + } + + /// + /// The probe short-circuit fires even when the server never returns a + /// CharacterList at all (GetCharacters returns null) — a probe is a + /// connectivity check, not itself a character-selection operation, so it + /// must not fall through to the NoCharacters path. + /// + [Fact] + public void Start_ProbeWithoutCharacterListStillCompletesGracefully() + { + var calls = new List(); + var operations = new TestOperations(calls) { Characters = null }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start( + LiveOptions(probe: true), + host); + + Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status); + Assert.Empty(host.Rosters); + Assert.Equal(0, operations.EnterWorldCount); + Assert.False(controller.IsInWorld); + } + [Theory] [InlineData("index")] [InlineData("id")] @@ -1141,14 +1216,16 @@ public sealed class LiveSessionControllerTests private static LiveSessionConnectOptions LiveOptions( bool live = true, string? user = "user", - LiveSessionCharacterSelector? selector = null) => + LiveSessionCharacterSelector? selector = null, + bool probe = false) => new( live, "127.0.0.1", 9000, user ?? string.Empty, "password", - selector); + selector, + probe); private static CharacterList.Parsed AvailableCharacters() => new( 0u, From 4edc122085c8c2888db8f519dc71e7eae5e90385 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:34:06 +0200 Subject: [PATCH 020/138] =?UTF-8?q?docs:=20Campaign=20LA=20handoff=20?= =?UTF-8?q?=E2=80=94=20worktree=20paths,=20stopped-agent=20recovery,=20kic?= =?UTF-8?q?koff=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both remaining agents were stopped for token budget and their partial work committed as WIP (75a6724d LA1 fix round, c6019424 LA2). The handoff now carries: full worktree paths with branches and HEADs, exactly what each stopped agent had finished versus what it still owes, and a paste-ready kickoff prompt naming all three resumable items plus the two owed merge items. Co-Authored-By: Claude Fable 5 --- .../2026-08-14-campaign-la-handoff.md | 134 ++++++++++++++---- 1 file changed, 108 insertions(+), 26 deletions(-) diff --git a/docs/research/2026-08-14-campaign-la-handoff.md b/docs/research/2026-08-14-campaign-la-handoff.md index cd7dd77c..8dcc588b 100644 --- a/docs/research/2026-08-14-campaign-la-handoff.md +++ b/docs/research/2026-08-14-campaign-la-handoff.md @@ -5,9 +5,17 @@ ledger), then `docs/superpowers/specs/2026-08-14-launcher-campaign-design.md` (the approved design).** Memory crib: `claude-memory/project_launcher_direction.md`. -Branch: `claude/acdream-launcher-credentials-4d2f7c` -Worktree: `.claude/worktrees/acdream-launcher-credentials-4d2f7c` -HEAD at handoff: `498f1c11` +## Worktrees (full paths — work in the campaign worktree, NOT the repo root) + +| Purpose | Full path | Branch | HEAD at handoff | +|---|---|---|---| +| **Campaign branch — START HERE** | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c` | `claude/acdream-launcher-credentials-4d2f7c` | `75a6724d` | +| LA2 slice | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la2` | `campaign-la2` | `c6019424` | +| LA3 slice | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la3` | `campaign-la3` | `26feba81` | +| LA7a slice (merged — removable) | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la7a` | `campaign-la7a` | `0c8643a7` | + +The repo root `C:\Users\erikn\source\repos\acdream` is on `main` and is NOT +where this campaign happens. --- @@ -48,8 +56,8 @@ Design decisions already made and NOT to be re-litigated (spec §2): | Slice | State | Commits | |---|---|---| | LA0 `AcDream.Platform` extraction | **DONE** (review closed) | `cb6502c8`, `a49e92df`, `7a839cba` | -| LA1 launch contract (App CLI + status writer + roster seam) | implemented; **Opus review returned FIX-FIRST**; fix round IN FLIGHT | `db9ad53c` (MIXED — see §4), note `e1322a06` | -| LA2 probe mode + idle policy | implementer IN FLIGHT | branch `campaign-la2` (base `498f1c11`) | +| LA1 launch contract (App CLI + status writer + roster seam) | implemented; Opus review FIX-FIRST; **fix round WIP — stopped mid-task**, see §3 | `db9ad53c` (MIXED — see §4), note `e1322a06`, WIP `75a6724d` | +| LA2 probe mode + idle policy | **WIP — stopped mid-task**, see §3 | `c6019424` on branch `campaign-la2` | | LA3 `AcDream.Launcher.Core` | implemented; review FIX-FIRST (12 findings); **fix round LANDED — all 12 fixed, 94/94 Windows + WSL**; owes narrow re-review, then merge | `37d74e44`, `26feba81` on branch `campaign-la3` | | LA7a character wire messages | **DONE + MERGED** | `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4` | | LA4 Avalonia UI | not started (needs LA3) | — | @@ -64,24 +72,37 @@ retail sends ≥16 bytes, we send 8; ACE ignores the tail). --- -## 3. Work IN FLIGHT at handoff — recover these first +## 3. Work STOPPED MID-TASK — resume these first -Three agents were running when this handoff was written. Their results arrive -as task notifications in the ORIGINAL session only; a new session must verify -state from git instead of waiting. +Two agents were **killed for token budget** and their partial work is +**committed as clearly-marked WIP**. Build/test state at both WIP commits is +UNVERIFIED — build and test before trusting either. -1. **LA1 fix round** — main worktree, branch - `claude/acdream-launcher-credentials-4d2f7c`. Findings: F1 (HIGH, required) - the `SessionStatusWriter` must never throw into the login/teardown - transactions and must create its parent directory (an unwritable/missing - status path currently fails a healthy session — first-run trigger); - F2 App reader must TOLERATE `process.paths` (parse-and-ignore, like the - existing `policy`) and explicitly REFUSE `mode: "probe"` with a named error; - F4 production-shape the shared fixture (`process.content`, `standardInput` - credential); F3 reconnect emits `disconnected` first + record the mid-play - drop limitation; F5–F8 minor hardening. -2. **LA2 implementer** — worktree `.claude/worktrees/acdream-la2`, branch - `campaign-la2`. +1. **LA1 fix round — WIP at `75a6724d`** (campaign worktree/branch). + DONE in the WIP: F1 best-effort `SessionStatusWriter` (never throws into + login/teardown, creates its parent directory), F2 App reader tolerance + (parse-and-ignore `process.paths`, explicit named refusal of + `mode: "probe"`), F5 `--session-config` argument hardening, new tests. + **STILL OWED:** F4 production-shape the shared fixture + (`tests/Fixtures/campaign-la/session-config-shared-fixture.json` — add + `process.content`, switch credential to `standardInput`/`session`, assert + values on BOTH sides; this was the agent's literal next step); F3 reconnect + emits `disconnected` before the second `connected` + record the mid-play + wire-drop limitation in the plan's status-stream section; F6 `exited` + idempotency + distinct reason strings; F7 make the Runtime redaction test + structural (assert the exact serialized property set per event kind); F8 + platform-guard file-set test + fix the overstating `isLinux` comment; + optional `RuntimeOptions.PrintMembers` redaction of `LivePass`. + Then: run Runtime/App/Headless Release suites (+ WSL for Runtime/Headless) + and dispatch the NARROW re-review. +2. **LA2 — WIP at `c6019424`** (worktree `...\acdream-la2`, branch + `campaign-la2`). DONE in the WIP: probe flag through + `LiveSessionConnectOptions`, the `StartCore` short-circuit before selection, + the `mode` field with the `JsonRequired`→semantic-validation move, host + exit-code mapping, 34 tests passing including 3 probe tests (agent's last + report before the stop). **STILL OWED:** idle-policy unit tests (its next + step), full Runtime+Headless Release suites on Windows AND WSL, then the + Opus dual-lens review. 3. **LA3 fix round — COMPLETE at `26feba81`** (worktree `.claude/worktrees/acdream-la3`, branch `campaign-la3`). All 12 findings fixed: the CRITICAL `"paths": {}` emission (now omitted entirely), probe @@ -161,7 +182,65 @@ re-derive the work). --- -## 6. The goal to set +## 6. Kickoff prompt for the new session + +Paste this as the FIRST message of the new session (it names the three +resumable work items explicitly), then set the goal in §7. + +```text +Resume Campaign LA (the acdream launcher). Work in +C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c +on branch claude/acdream-launcher-credentials-4d2f7c. Read +docs/research/2026-08-14-campaign-la-handoff.md first, then the ledger in +docs/plans/2026-08-14-launcher-campaign.md. + +Three items are waiting, all recoverable from git — two are partial work from +agents that were stopped mid-task for token budget, and their build/test state +is UNVERIFIED: + +1. LA1 fix round — WIP commit 75a6724d on this branch. An agent had completed + findings F1 (best-effort SessionStatusWriter that never throws into the + login/teardown transactions and creates its parent directory), F2 (App + reader tolerates process.paths and explicitly refuses mode:"probe"), and F5 + (--session-config argument hardening). It was stopped just as it started F4. + Finish: F4 production-shape tests/Fixtures/campaign-la/session-config-shared-fixture.json + (add process.content, switch the credential to standardInput/session, assert + values in BOTH host suites), F3 (reconnect emits disconnected before the + second connected; record the mid-play wire-drop limitation in the plan's + status-stream section), F6 (exited idempotency + distinct reason strings), + F7 (make the Runtime redaction test structural — assert the exact serialized + property set per event kind), F8 (platform-guard file-set test + fix the + overstating isLinux comment), and optionally redact LivePass from + RuntimeOptions.PrintMembers. Then build, run Runtime/App/Headless Release + suites plus WSL for Runtime/Headless, and dispatch the narrow re-review. + +2. LA2 — WIP commit c6019424 in worktree + C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la2 (branch + campaign-la2). An agent had implemented the probe flag through + LiveSessionConnectOptions, the StartCore short-circuit before selection, the + mode field with the JsonRequired-to-semantic-validation move, and the host + exit-code mapping, with 34 tests green including 3 probe tests. It was + stopped before writing the idle-policy unit tests. Finish those, run the + Runtime+Headless Release suites on Windows and WSL, then dispatch the Opus + dual-lens review. + +3. LA3 — COMPLETE at 26feba81 in worktree + C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la3 (branch + campaign-la3). All 12 review findings fixed, 94/94 Windows and WSL. It needs + only a narrow Opus re-review of 26feba81 against the finding list in §3 of + the handoff, then merge into the campaign branch. + +At the LA1+LA3 merge, do not lose the two owed items: the cross-assembly +contract test (feed a Launcher.Core composer document to BOTH host loaders) and +adding tests/AcDream.Launcher.Core.Tests to the Linux CI lane in +.github/workflows/headless-portability.yml. + +After those land, continue the ledger: LA4 (Avalonia UI), LA5 (plugin hosting), +LA6 (login commands), LA7b (char-select state+flow), LA8 (authored screen), LA9 +(installer), LA10 (updater), LA11 (closeout). +``` + +## 7. The goal to set Set this with `/goal` in the new session (it is the same directive this session ran under, refreshed for the current state): @@ -169,10 +248,13 @@ ran under, refreshed for the current state): ```text GOAL: Ship Campaign LA — the acdream launcher/installer/updater + retail character-select screen. -Start at docs/research/2026-08-14-campaign-la-handoff.md, then the ledger in -docs/plans/2026-08-14-launcher-campaign.md. Recover the three in-flight slices -first (LA1 fix round on the campaign branch, LA2 on campaign-la2, LA3 fix round -on campaign-la3) by inspecting git state, then continue slice by slice. +Work in C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c +(branch claude/acdream-launcher-credentials-4d2f7c). Start at +docs/research/2026-08-14-campaign-la-handoff.md, then the ledger in +docs/plans/2026-08-14-launcher-campaign.md. Finish the three waiting items +first — LA1 fix round (WIP 75a6724d, unverified), LA2 (WIP c6019424 on +campaign-la2, unverified), LA3 (complete at 26feba81 on campaign-la3, owes a +narrow re-review then merge) — then continue slice by slice. Process, per slice: 1. Fable plans/sequences/integrates — never present work-order menus; pick and announce. From 000ea979d5219c21beef06ac8fcd65e510ad71d2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:49:51 +0200 Subject: [PATCH 021/138] test: Campaign LA finish LA2 probe and idle gates Prove idle play remains passive and live until cancellation, then converges through one truthful status teardown. Keep probe mode string-only so numeric enum aliases cannot expand the pinned v1 contract, and record the Windows/WSL gates. --- docs/plans/2026-08-14-launcher-campaign.md | 2 +- .../Configuration/HeadlessConfiguration.cs | 6 +- .../HeadlessBotPolicyTests.cs | 77 ++++++++ .../HeadlessConfigurationLoaderTests.cs | 32 ++++ .../HeadlessSessionHostTests.cs | 164 ++++++++++++++---- 5 files changed, 243 insertions(+), 38 deletions(-) create mode 100644 tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index c904885e..ae9586e1 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -478,7 +478,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. |---|---|---|---|---| | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers | -| LA2 | — | | | | +| LA2 | implementation complete; automated gates **GREEN**; Opus dual-lens review pending | `c6019424` + completion (this commit) | pending | Probe is roster-before-selection with graceful pre-world teardown and exit 0; normal play remains strict selector + `idle` policy. Release build green; Runtime 1,632/1,632 and Headless 141/141 on both Windows and Ubuntu/WSL | | LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | | LA4 | — | | | | | LA5 | — | | | | diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index fbdbdbe9..1ad3a913 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -167,9 +167,11 @@ internal sealed class HeadlessBotPolicyDescriptor /// The pinned launch-contract schema defines exactly two states for a /// session — ABSENT (mapped to , meaning "play") or /// the literal string "probe" — so is the only -/// member; there is no explicit "play" spelling. +/// member; there is no explicit "play" spelling. This deliberately uses +/// 's global camel-case, +/// string-only enum converter; a per-enum converter with its default options +/// would accidentally accept numeric 0 as a second probe spelling. /// -[JsonConverter(typeof(JsonStringEnumConverter))] internal enum HeadlessSessionMode { Probe, diff --git a/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs b/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs new file mode 100644 index 00000000..2ea2f73a --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using AcDream.Headless.Policies; +using AcDream.Runtime; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessBotPolicyTests +{ + /// + /// Campaign LA slice LA2: idle is a deliberately passive, + /// non-terminal policy. It must neither inspect Runtime state nor reach + /// any command surface, and no event can make it complete on its own. + /// The process scheduler therefore keeps the play session alive until + /// external cancellation/stop drives the host's ordinary teardown path. + /// + [Fact] + public void IdlePolicyIsPassiveAndNeverCompletesAutonomously() + { + var policy = new IdleHeadlessBotPolicy(); + IGameRuntimeView view = CreateNoTouchProxy( + out InvocationCountingProxy viewCalls); + IGameRuntimeCommands commands = + CreateNoTouchProxy( + out InvocationCountingProxy commandCalls); + + for (int index = 0; index < 3; index++) + policy.Tick(view, commands); + + RuntimeLifecycleDelta lifecycle = default; + RuntimeCommandDelta command = default; + RuntimeEntityDelta entity = default; + RuntimeInventoryDelta inventory = default; + RuntimeChatDelta chat = default; + RuntimeMovementDelta movement = default; + RuntimePortalDelta portal = default; + RuntimeCombatDelta combat = default; + policy.OnLifecycle(in lifecycle); + policy.OnCommand(in command); + policy.OnEntity(in entity); + policy.OnInventory(in inventory); + policy.OnChat(in chat); + policy.OnMovement(in movement); + policy.OnPortal(in portal); + policy.OnCombat(in combat); + + Assert.False(policy.IsComplete); + Assert.Equal(0, viewCalls.InvocationCount); + Assert.Equal(0, commandCalls.InvocationCount); + + policy.Dispose(); + policy.Dispose(); + Assert.False(policy.IsComplete); + } + + private static T CreateNoTouchProxy( + out InvocationCountingProxy proxy) + where T : class + { + T value = DispatchProxy.Create(); + proxy = (InvocationCountingProxy)(object)value; + return value; + } + + public class InvocationCountingProxy : DispatchProxy + { + public int InvocationCount { get; private set; } + + protected override object? Invoke( + MethodInfo? targetMethod, + object?[]? args) + { + InvocationCount++; + throw new InvalidOperationException( + $"Idle policy unexpectedly invoked {targetMethod?.Name}."); + } + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs index 9c21273c..6902fccc 100644 --- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -237,6 +237,38 @@ public sealed class HeadlessConfigurationLoaderTests Assert.Null(session.Policy); } + /// + /// The pinned v1 contract has one named mode value: "probe". + /// In particular, the enum's underlying numeric zero must not become an + /// accidental second spelling through an enum converter configured to + /// allow integers. + /// + [Theory] + [InlineData("\"play\"")] + [InlineData("0")] + public void SessionModeRejectsEveryValueOtherThanTheNamedProbeMode( + string modeJson) + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + $$""" + { + "version": 1, + "sessions": [ + { + "id": "unsupported-mode", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": {{modeJson}}, + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + [Fact] public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 332a9933..f568feb2 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Collections.Immutable; +using System.Diagnostics; using System.Net; using System.Numerics; using System.Reflection; @@ -315,41 +316,122 @@ public sealed class HeadlessSessionHostTests Assert.False(playSession.IsFaulted); } + /// + /// Campaign LA slice LA2: the configured idle policy follows the + /// normal play shape (selector + policy, mode absent), enters world, and + /// remains non-terminal through real scheduler turns until cancellation. + /// Cancellation stops the process loop; the owning host's ordinary + /// disposal transaction then performs graceful session teardown. Status + /// events must describe those boundaries truthfully and remain exactly + /// once even when disposal is repeated. + /// [Fact] - public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() + public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce() { - var configuration = new HeadlessConfiguration + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl"); + try { - Version = 1, - Sessions = - [ - Descriptor( - HeadlessCredentialProviderKind.StandardInput, - "stdin-bot"), - ], - }; - HeadlessPathSet paths = HeadlessPathSet.Resolve( - new HeadlessPathOverrides()); - using var diagnostics = new StringWriter(); - var operations = new FixtureSessionOperations(); - using var host = new HeadlessProcessHost( - configuration, - paths, - new System.IO.StringReader( - "process-password" + Environment.NewLine), - diagnostics, - operations); - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + Descriptor( + HeadlessCredentialProviderKind.StandardInput, + "stdin-bot", + statusFile: statusPath), + ], + }; + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides()); + using var diagnostics = new StringWriter(); + var operations = new FixtureSessionOperations(); + using var host = new HeadlessProcessHost( + configuration, + paths, + new System.IO.StringReader( + "process-password" + Environment.NewLine), + diagnostics, + operations); + using var cancellation = new CancellationTokenSource(); - HeadlessExitCode result = - await host.RunAsync(cancellation.Token); + Task run = host.RunAsync(cancellation.Token); + var timeout = Stopwatch.StartNew(); + while (operations.TickCallCount < 3 + && !run.IsCompleted + && timeout.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(5); + } - Assert.Equal(HeadlessExitCode.Success, result); - Assert.True(host.Session.Runtime.Session.IsInWorld); - Assert.DoesNotContain( - "process-password", - diagnostics.ToString()); + Assert.True( + operations.TickCallCount >= 3, + $"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}."); + Assert.False(run.IsCompleted); + Assert.Equal(1, operations.EnterWorldCallCount); + Assert.Equal("Headless", host.Session.ActiveCharacterName); + Assert.True(host.Session.Runtime.Session.IsInWorld); + Assert.False(host.Session.IsPolicyComplete); + Assert.Equal( + ["started", "connected", "characterList", "enteredWorld"], + ReadStatusEventNames(statusPath)); + + cancellation.Cancel(); + HeadlessExitCode result = await run.WaitAsync( + TimeSpan.FromSeconds(10)); + + Assert.Equal(HeadlessExitCode.Success, result); + // RunAsync owns scheduling, not the host lifetime. The session + // remains honestly connected until its owner disposes it. + Assert.True(host.Session.Runtime.Session.IsInWorld); + Assert.Equal( + ["started", "connected", "characterList", "enteredWorld"], + ReadStatusEventNames(statusPath)); + + host.Dispose(); + host.Dispose(); + + Assert.True(host.Session.Runtime.CaptureOwnership().IsConverged); + Assert.Equal(1, operations.DisposedSessionCount); + string[] lines = File.ReadAllLines(statusPath); + string[] eventNames = ReadStatusEventNames(statusPath); + Assert.Equal( + [ + "started", "connected", "characterList", "enteredWorld", + "disconnected", "exited", + ], + eventNames); + + using JsonDocument disconnected = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "disconnected")]); + Assert.Equal( + "stopped", + disconnected.RootElement.GetProperty("reason").GetString()); + + using JsonDocument exited = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "exited")]); + JsonElement exit = exited.RootElement; + Assert.Equal(0, exit.GetProperty("code").GetInt32()); + string? exitReason = exit.GetProperty("reason").GetString(); + Assert.False(string.IsNullOrWhiteSpace(exitReason)); + Assert.NotEqual("fault", exitReason); + Assert.NotEqual("probe", exitReason); + Assert.DoesNotContain( + "process-password", + File.ReadAllText(statusPath), + StringComparison.Ordinal); + Assert.DoesNotContain( + "process-password", + diagnostics.ToString(), + StringComparison.Ordinal); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } } [Fact] @@ -2276,6 +2358,15 @@ public sealed class HeadlessSessionHostTests StatusFile = statusFile, }; + private static string[] ReadStatusEventNames(string path) => + File.ReadAllLines(path) + .Select(static line => + { + using JsonDocument document = JsonDocument.Parse(line); + return document.RootElement.GetProperty("e").GetString()!; + }) + .ToArray(); + private static void HydrateGroundedPlayer(GameRuntime runtime) { const uint player = 0x50000002u; @@ -2917,11 +3008,17 @@ public sealed class HeadlessSessionHostTests private sealed class FixtureSessionOperations : ILiveSessionOperations { + private int _enterWorldCallCount; + private int _tickCallCount; + public List Sessions { get; } = []; public int CreatedSessionCount { get; private set; } public int DisposedSessionCount { get; private set; } public string? LastUser { get; private set; } public string? LastPassword { get; private set; } + public int EnterWorldCallCount => + Volatile.Read(ref _enterWorldCallCount); + public int TickCallCount => Volatile.Read(ref _tickCallCount); public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); @@ -2963,19 +3060,16 @@ public sealed class HeadlessSessionHostTests true, true); - /// Campaign LA slice LA2: lets a probe test assert the - /// live-session controller never reached EnterWorld. - public int EnterWorldCallCount { get; private set; } - public void EnterWorld( WorldSession session, int activeCharacterIndex) { - EnterWorldCallCount++; + Interlocked.Increment(ref _enterWorldCallCount); } public void Tick(WorldSession session) { + Interlocked.Increment(ref _tickCallCount); } public void DisposeSession(WorldSession session) From d511e4c348f2cb72f0ee08bd16d1b344f5cb159f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:00:09 +0200 Subject: [PATCH 022/138] fix(launcher): close Campaign LA LA1 review findings --- docs/plans/2026-08-14-launcher-campaign.md | 10 + .../Credentials/AppCredentialResolver.cs | 11 +- src/AcDream.App/Rendering/GameWindow.cs | 2 +- src/AcDream.App/RuntimeOptions.cs | 40 ++++ .../Hosting/HeadlessSessionHost.cs | 15 +- .../Session/SessionStatusWriter.cs | 211 ++++++++++++++---- .../SessionConfigurationSharedFixtureTests.cs | 12 +- .../Rendering/LinuxPlatformBoundaryTests.cs | 21 ++ .../AcDream.App.Tests/RuntimeOptionsTests.cs | 20 ++ .../HeadlessSessionHostTests.cs | 67 ++++++ .../SessionConfigurationSharedFixtureTests.cs | 12 +- .../Session/SessionStatusWriterTests.cs | 57 +++++ 12 files changed, 413 insertions(+), 65 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index c904885e..5fb63247 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -171,6 +171,16 @@ sides. Unknown `e` values must parse to a typed Unknown event, never throw; a known `e` with a wrong payload shape should be distinguishable from an unknown `e` (LA3 review finding 12). +**Known LA1 status limitation:** the stream has no independent mid-play +wire-drop detector. If a transport becomes silent without raising through the +host's tick/teardown path, no immediate `disconnected` line can be promised; +the launcher must not treat the absence of that line as proof that the socket +is healthy. Explicit reconnect is ordered and observable — it emits +`disconnected{reason:"reconnect"}` before the replacement connection's second +`connected` — and normal stop/process teardown closes any still-open +connection before `exited`. A future transport-health signal may improve the +timing without changing this pinned event vocabulary. + Three pieces, one slice, because they share the session-config/status seam: 1. **App `--session-config `:** parsed once in `Program.cs` into diff --git a/src/AcDream.App/Credentials/AppCredentialResolver.cs b/src/AcDream.App/Credentials/AppCredentialResolver.cs index 7d52fdab..a311b77e 100644 --- a/src/AcDream.App/Credentials/AppCredentialResolver.cs +++ b/src/AcDream.App/Credentials/AppCredentialResolver.cs @@ -29,11 +29,12 @@ internal sealed class AppCredentialResolver private readonly bool _isLinux; /// - /// is caller-supplied, never detected in this - /// file — LinuxPlatformBoundaryTests's platform-owner guard - /// requires every OS-family check to live under Platform/; - /// callers pass GraphicalHostPlatformServices's already-detected - /// value instead of this file re-detecting it itself. + /// is the caller-supplied platform-policy + /// value from GraphicalHostPlatformServices. This file still uses + /// RuntimePlatformGuard.IsLinuxRuntime below as the narrow + /// CA1416-recognized runtime guard required before calling + /// File.GetUnixFileMode; it does not independently select the host + /// platform or bypass the platform-services owner. /// internal AppCredentialResolver( TextReader standardInput, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 19cd8c44..f3be7ef2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1665,7 +1665,7 @@ public sealed class GameWindow : // OnClosing() native-window-close-request pass) represents the // process actually being done. if (releaseNativeWindow) - _statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed"); + _statusWriter.Exited(_options.SessionId ?? "app", 0, "graceful"); return; } diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 7b3266d1..b0c4bd72 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Reflection; +using System.Text; using AcDream.App.Configuration; using AcDream.App.Rendering.Residency; using AcDream.App.Streaming; @@ -266,6 +268,44 @@ public sealed record RuntimeOptions( selector.Id, selector.Name); + private static readonly PropertyInfo[] PrintableProperties = + typeof(RuntimeOptions) + .GetProperties( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.DeclaredOnly) + .Where(static property => + property.GetMethod is not null + && property.GetIndexParameters().Length == 0) + .OrderBy(static property => property.MetadataToken) + .ToArray(); + + /// + /// Campaign LA LA1 defense in depth: positional records normally print + /// every public property, including the live password. Preserve that + /// ordinary diagnostic property set while substituting the one sensitive + /// value before it can reach a log, debugger display, or exception. + /// Reflection is cached once and runs only on the diagnostic + /// path. + /// + private bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + for (int index = 0; index < PrintableProperties.Length; index++) + { + PropertyInfo property = PrintableProperties[index]; + if (index != 0) + builder.Append(", "); + builder.Append(property.Name); + builder.Append(" = "); + builder.Append( + property.Name == nameof(LivePass) && LivePass is not null + ? "" + : property.GetValue(this)); + } + return PrintableProperties.Length != 0; + } + /// True iff live-mode credentials are present and valid for connecting. public bool HasLiveCredentials => LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index ea3a56d4..b8b7406e 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -491,8 +491,9 @@ internal sealed class HeadlessSessionHost : IDisposable _policy.Tick(Runtime, Commands); } - internal RuntimeTeardownAcknowledgement Stop() + internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped") { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); RuntimeTeardownAcknowledgement result = Commands.Session.Stop(Runtime.Generation); // R9 review fix (2026-08-03): _currentSession is cached across @@ -510,7 +511,7 @@ internal sealed class HeadlessSessionHost : IDisposable if (_hasConnected) { _hasConnected = false; - _statusWriter.Disconnected(_descriptor.Id, "stopped"); + _statusWriter.Disconnected(_descriptor.Id, reason); } return result; } @@ -640,7 +641,7 @@ internal sealed class HeadlessSessionHost : IDisposable _statusWriter.Exited( _descriptor.Id, _faulted ? 1 : 0, - _faulted ? "fault" : "disposed"); + _faulted ? "runtime-fault" : "graceful"); _disposeStage++; _disposed = true; break; @@ -670,8 +671,12 @@ internal sealed class HeadlessSessionHost : IDisposable if (reconnect) { - RuntimeTeardownAcknowledgement stopped = - _liveSession.Stop(expectedGeneration); + // Campaign LA LA1 review fix F3: route reconnect teardown + // through the same status-aware Stop boundary as every other + // host stop. The retiring connection therefore publishes a + // truthful disconnected(reason: "reconnect") edge before the + // fresh LiveSessionHost reports its second connected edge. + RuntimeTeardownAcknowledgement stopped = Stop("reconnect"); if (!stopped.IsComplete) { return new RuntimeSessionStartResult( diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index 6ccb27fe..b98e4761 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -35,6 +35,18 @@ namespace AcDream.Runtime.Session; /// /// /// +/// The writer also owns the small amount of stream-ordering state needed to +/// keep the external contract coherent across host implementations. A second +/// connected edge while the prior connection is still open first emits +/// disconnected(reason: "reconnect"); a terminal exited edge +/// closes any still-open connection with +/// disconnected(reason: "process-exit"). exited is terminal and +/// idempotent: the first call wins and every later event is ignored. This is +/// deliberately enforced here because both graphical and no-window hosts use +/// this exact sink, while their reconnect command adapters are separate. +/// +/// +/// /// This writer can never fail or stall the session transaction it /// observes (Campaign LA LA1 review fix F1). Every call site sits /// inside a caller-owned try block that treats a throw as a real failure — @@ -91,6 +103,8 @@ public sealed class SessionStatusWriter private readonly object _gate = new(); private bool _directoryEnsured; private bool _latchedOff; + private bool _connected; + private bool _exited; public SessionStatusWriter(string? path, TimeProvider? timeProvider = null) { @@ -116,14 +130,44 @@ public sealed class SessionStatusWriter sessionId, }); - public void Connected(string sessionId) => - Write(new + public void Connected(string sessionId) + { + if (!IsEnabled) + return; + + lock (_gate) { - v = VocabularyVersion, - e = "connected", - t = Now(), - sessionId, - }); + if (_latchedOff || _exited) + return; + + if (_connected) + { + if (!TryWriteLocked(new + { + v = VocabularyVersion, + e = "disconnected", + t = Now(), + sessionId, + reason = "reconnect", + })) + { + return; + } + _connected = false; + } + + if (TryWriteLocked(new + { + v = VocabularyVersion, + e = "connected", + t = Now(), + sessionId, + })) + { + _connected = true; + } + } + } public void CharacterList(string sessionId, LiveSessionRosterReport roster) { @@ -161,26 +205,70 @@ public sealed class SessionStatusWriter characterName, }); - public void Disconnected(string sessionId, string reason) => - Write(new - { - v = VocabularyVersion, - e = "disconnected", - t = Now(), - sessionId, - reason, - }); + public void Disconnected(string sessionId, string reason) + { + if (!IsEnabled) + return; - public void Exited(string sessionId, int code, string reason) => - Write(new + lock (_gate) { - v = VocabularyVersion, - e = "exited", - t = Now(), - sessionId, - code, - reason, - }); + if (_latchedOff || _exited) + return; + + if (TryWriteLocked(new + { + v = VocabularyVersion, + e = "disconnected", + t = Now(), + sessionId, + reason, + })) + { + _connected = false; + } + } + } + + public void Exited(string sessionId, int code, string reason) + { + if (!IsEnabled) + return; + + lock (_gate) + { + if (_latchedOff || _exited) + return; + + if (_connected) + { + if (!TryWriteLocked(new + { + v = VocabularyVersion, + e = "disconnected", + t = Now(), + sessionId, + reason = "process-exit", + })) + { + return; + } + _connected = false; + } + + if (TryWriteLocked(new + { + v = VocabularyVersion, + e = "exited", + t = Now(), + sessionId, + code, + reason, + })) + { + _exited = true; + } + } + } private string Now() => _timeProvider.GetUtcNow().ToString( @@ -189,7 +277,7 @@ public sealed class SessionStatusWriter private void Write(T value) { - if (_path is not { } path || _latchedOff) + if (_path is null || _latchedOff) return; lock (_gate) @@ -197,26 +285,41 @@ public sealed class SessionStatusWriter // Re-check inside the lock: another thread may have latched the // writer off (or already ensured the directory) between the // fast check above and taking the gate. - if (_latchedOff) + if (_latchedOff || _exited) return; - try - { - EnsureDirectory(path); - string line = JsonSerializer.Serialize(value, JsonOptions); - using FileStream stream = new( - path, - FileMode.Append, - FileAccess.Write, - FileShare.Read); - using var writer = new StreamWriter(stream); - writer.WriteLine(line); - writer.Flush(); - } - catch (Exception error) when (IsRecoverableIoFailure(error)) - { - LatchOff(path, error); - } + _ = TryWriteLocked(value); + } + } + + /// + /// Writes one event while is held. Returning success + /// lets the lifecycle methods publish their state transition only after + /// the matching line has reached the stream. A recoverable I/O failure + /// latches the writer off, so there is never a retry that could duplicate + /// an uncertain terminal edge. + /// + private bool TryWriteLocked(T value) + { + string path = _path!; + try + { + EnsureDirectory(path); + string line = JsonSerializer.Serialize(value, JsonOptions); + using FileStream stream = new( + path, + FileMode.Append, + FileAccess.Write, + FileShare.Read); + using var writer = new StreamWriter(stream); + writer.WriteLine(line); + writer.Flush(); + return true; + } + catch (Exception error) when (IsRecoverableIoFailure(error)) + { + LatchOff(path, error); + return false; } } @@ -234,10 +337,22 @@ public sealed class SessionStatusWriter private void LatchOff(string path, Exception error) { _latchedOff = true; - Console.Error.WriteLine( - $"[status-writer] disabling status stream at '{path}' after a " - + $"write failure ({error.GetType().Name}: {error.Message}); no " - + "further events for this session will be written."); + try + { + Console.Error.WriteLine( + $"[status-writer] disabling status stream at '{path}' after a " + + $"write failure ({error.GetType().Name}: {error.Message}); no " + + "further events for this session will be written."); + } + catch (Exception diagnosticError) + when (IsRecoverableIoFailure(diagnosticError) + || diagnosticError is ObjectDisposedException + or InvalidOperationException) + { + // This is the fallback diagnostic for an already-failed + // observability sink. A closed/broken stderr must not turn it + // back into a session-transaction failure. + } } /// diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs index b5b44ebd..1760946b 100644 --- a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs @@ -19,12 +19,18 @@ namespace AcDream.App.Tests.Configuration; public sealed class SessionConfigurationSharedFixtureTests { [Fact] - public void AppReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields() + public void AppReaderAcceptsTheProductionShapedSharedFixture() { (SessionConfiguration configuration, SessionDescriptor session) = SessionConfigurationLoader.Load(SharedFixturePath()); Assert.Equal(1, configuration.Version); + Assert.Equal( + "shared-fixture-dats", + configuration.Process?.Content?.DatDirectory); + Assert.Equal( + "shared-fixture-dats/acdream.pak", + configuration.Process?.Content?.PreparedAssetPath); Assert.Equal("shared-fixture", session.Id); Assert.Equal("127.0.0.1", session.Endpoint.Host); Assert.Equal(9000, session.Endpoint.Port); @@ -34,9 +40,9 @@ public sealed class SessionConfigurationSharedFixtureTests // the pinned contract's "parsed-and-ignored" clause. Assert.Equal("idle", session.Policy?.Id); Assert.Equal( - SessionCredentialProviderKind.Environment, + SessionCredentialProviderKind.StandardInput, session.Credential.Provider); - Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference); + Assert.Equal("session", session.Credential.Reference); Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins); Assert.Equal( diff --git a/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs index bb53f82b..23547a96 100644 --- a/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs @@ -87,6 +87,27 @@ public sealed class LinuxPlatformBoundaryTests Assert.Empty(offenders); } + [Fact] + public void RuntimePlatformGuardHasOneDefinitionAndOneApprovedConsumer() + { + string app = AppSourceRoot(); + string[] files = Directory + .EnumerateFiles(app, "*.cs", SearchOption.AllDirectories) + .Where(path => File.ReadAllText(path).Contains( + "RuntimePlatformGuard", + StringComparison.Ordinal)) + .Select(path => Path.GetRelativePath(app, path).Replace('\\', '/')) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + "Credentials/AppCredentialResolver.cs", + "Platform/GraphicalHostPlatformServices.cs", + ], + files); + } + [Fact] public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths() { diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 3970c486..2fdab9a6 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -234,6 +234,26 @@ public sealed class RuntimeOptionsTests Assert.Equal("testpassword", realValues.LivePass); } + [Fact] + public void RecordPrintMembersRedactsTheLivePassword() + { + RuntimeOptions options = RuntimeOptions.Parse( + AnyDatDir, + Env(new() + { + ["ACDREAM_LIVE"] = "1", + ["ACDREAM_TEST_USER"] = "testaccount", + ["ACDREAM_TEST_PASS"] = "top-secret-value", + })); + + string printed = options.ToString(); + + Assert.DoesNotContain("top-secret-value", printed, StringComparison.Ordinal); + Assert.Contains("LivePass = ", printed, StringComparison.Ordinal); + Assert.Contains("LiveHost = 127.0.0.1", printed, StringComparison.Ordinal); + Assert.Contains("HasLiveCredentials = True", printed, StringComparison.Ordinal); + } + [Fact] public void HasLiveCredentials_RequiresLiveModeAndBothUserAndPass() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 5c9871f5..c4fc0fb2 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -62,6 +62,73 @@ public sealed class HeadlessSessionHostTests Assert.DoesNotContain("AcDream.App", diagnostics); } + /// + /// Campaign LA LA1 review fixes F3/F6: reconnect is a visible lifecycle + /// replacement, so the retiring connection must publish disconnected + /// before the new connection publishes connected. Its reason is distinct + /// from the final host stop and from the terminal process outcome. + /// + [Fact] + public void ReconnectPublishesDisconnectedBeforeTheSecondConnectedEdge() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-reconnect-status-{Guid.NewGuid():N}.jsonl"); + try + { + var operations = new FixtureSessionOperations(); + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(statusFile: statusPath), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Reconnect().Status); + host.Dispose(); + + JsonElement[] events = File.ReadAllLines(statusPath) + .Select(static line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + Assert.Equal( + [ + "started", "connected", "characterList", "enteredWorld", + "disconnected", "connected", "characterList", "enteredWorld", + "disconnected", "exited", + ], + events.Select(static item => item.GetProperty("e").GetString())); + + JsonElement[] disconnected = events + .Where(static item => + item.GetProperty("e").GetString() == "disconnected") + .ToArray(); + Assert.Equal(2, disconnected.Length); + Assert.Equal( + "reconnect", + disconnected[0].GetProperty("reason").GetString()); + Assert.Equal( + "stopped", + disconnected[1].GetProperty("reason").GetString()); + + JsonElement exited = events[^1]; + Assert.Equal(0, exited.GetProperty("code").GetInt32()); + Assert.Equal("graceful", exited.GetProperty("reason").GetString()); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + /// /// Campaign LA slice LA1: proves the status-event writer fires the /// pinned lifecycle vocabulary — started/connected/characterList/ diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs index 631a6c1d..20a86697 100644 --- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -18,11 +18,17 @@ namespace AcDream.Headless.Tests; public sealed class SessionConfigurationSharedFixtureTests { [Fact] - public void HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields() + public void HeadlessReaderAcceptsTheProductionShapedSharedFixture() { HeadlessConfiguration configuration = HeadlessConfigurationLoader.Load(SharedFixturePath()); + Assert.Equal( + "shared-fixture-dats", + configuration.Process.Content?.DatDirectory); + Assert.Equal( + "shared-fixture-dats/acdream.pak", + configuration.Process.Content?.PreparedAssetPath); HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!; Assert.Equal("shared-fixture", session.Id); Assert.Equal("127.0.0.1", session.Endpoint.Host); @@ -31,9 +37,9 @@ public sealed class SessionConfigurationSharedFixtureTests Assert.Equal("SharedToon", session.Character.Name); Assert.Equal("idle", session.Policy.Id); Assert.Equal( - HeadlessCredentialProviderKind.Environment, + HeadlessCredentialProviderKind.StandardInput, session.Credential.Provider); - Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference); + Assert.Equal("session", session.Credential.Reference); Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins); Assert.Equal( diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index c1bfc020..899c0e0f 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -97,6 +97,63 @@ public sealed class SessionStatusWriterTests writer.Started("s1"); } + /// + /// F3: both hosts share this writer but have separate reconnect command + /// adapters. The sink therefore closes an open connection before it + /// accepts another connected edge, preserving a coherent external + /// lifecycle even if a host has no reconnect-specific status hook. + /// + [Fact] + public void SecondConnectedEdgeFirstClosesTheRetiringConnection() + { + using TemporaryFile file = TemporaryFile.Create(); + var writer = new SessionStatusWriter(file.Path); + + writer.Connected("s1"); + writer.Connected("s1"); + + JsonElement[] events = File.ReadAllLines(file.Path) + .Select(Parse) + .ToArray(); + Assert.Equal( + ["connected", "disconnected", "connected"], + events.Select(static item => item.GetProperty("e").GetString())); + Assert.Equal( + "reconnect", + events[1].GetProperty("reason").GetString()); + } + + /// + /// F6: exited is a terminal fact, not an append request. Repeated host + /// disposal and any late callback after disposal must not create a second + /// terminal edge or resurrect the stream. If a host exits while still + /// connected, the writer closes that connection first with a distinct, + /// truthful reason. + /// + [Fact] + public void ExitedIsIdempotentTerminalAndClosesAnOpenConnection() + { + using TemporaryFile file = TemporaryFile.Create(); + var writer = new SessionStatusWriter(file.Path); + + writer.Connected("s1"); + writer.Exited("s1", 0, "graceful"); + writer.Exited("s1", 1, "duplicate-must-not-win"); + writer.Connected("s1"); + + JsonElement[] events = File.ReadAllLines(file.Path) + .Select(Parse) + .ToArray(); + Assert.Equal( + ["connected", "disconnected", "exited"], + events.Select(static item => item.GetProperty("e").GetString())); + Assert.Equal( + "process-exit", + events[1].GetProperty("reason").GetString()); + Assert.Equal(0, events[2].GetProperty("code").GetInt32()); + Assert.Equal("graceful", events[2].GetProperty("reason").GetString()); + } + /// /// F7 (Campaign LA LA1 review fix round): replaces the earlier /// "DoesNotContain 'hunter2'/'password'" assertion, which could never From 890cf267fc99a17e73fac1ea81191fd2454ab22c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:00:46 +0200 Subject: [PATCH 023/138] docs(launcher): record Campaign LA LA1 fix-round gates --- docs/plans/2026-08-14-launcher-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 5fb63247..a5bc0142 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -487,7 +487,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | -| LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers | +| LA1 | fix round complete; narrow Opus re-review pending | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (WIP), `d511e4c3` (fix round) | Initial review FIX FIRST; findings F1–F8 addressed; narrow re-review pending | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Production-shaped shared fixture values asserted by BOTH host readers; known mid-play silent-wire-drop limitation recorded above. | | LA2 | — | | | | | LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | | LA4 | — | | | | From 347a1a5d167dcd2a48b1b2380954d55a62bf94de Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:06:47 +0200 Subject: [PATCH 024/138] fix(launcher): Campaign LA LA3 narrow review fixes --- .../AcDream.Launcher.Core.csproj | 3 + .../Launching/LauncherProcessSupervisor.cs | 105 ++++++++- .../Profiles/LauncherProfileStore.cs | 60 ++++-- .../Status/StatusEvent.cs | 14 +- .../Status/StatusEventParser.cs | 202 ++++++++++++++---- .../LauncherProcessSupervisorTests.cs | 114 +++++++++- .../Profiles/LauncherProfileStoreTests.cs | 87 +++----- .../Status/StatusEventParserTests.cs | 40 ++++ .../Status/StatusFileTailerTests.cs | 15 ++ 9 files changed, 495 insertions(+), 145 deletions(-) diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj index f31af6e8..85dc49f0 100644 --- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -12,6 +12,9 @@ context. --> true + + + diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index f9b10694..0f7fcac1 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -1,3 +1,5 @@ +using System.Runtime.ExceptionServices; + namespace AcDream.Launcher.Core.Launching; /// @@ -10,19 +12,41 @@ public sealed class LauncherProcessSupervisor : IDisposable { private readonly ILauncherChildProcessFactory _factory; private readonly object _gate = new(); + private readonly Queue _pendingStateChanges = []; private ILauncherChildProcess? _process; + private LauncherSessionState _state = LauncherSessionState.Starting; + private int? _exitCode; + private bool _publishingStateChanges; public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) { _factory = factory ?? new SystemChildProcessFactory(); } - public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting; + public LauncherSessionState State + { + get + { + lock (_gate) + { + return _state; + } + } + } /// Set once reaches /// ; null before then. /// - public int? ExitCode { get; private set; } + public int? ExitCode + { + get + { + lock (_gate) + { + return _exitCode; + } + } + } /// Fires on every /// transition, in order. @@ -148,14 +172,15 @@ public sealed class LauncherProcessSupervisor : IDisposable private void OnProcessExited(object? sender, EventArgs e) { - ILauncherChildProcess? process; + int? exitCode; lock (_gate) { - process = _process; + exitCode = _process is { HasExited: true } process + ? process.ExitCode + : null; } - ExitCode = process is { HasExited: true } ? process.ExitCode : null; - SetState(LauncherSessionState.Exited); + SetState(LauncherSessionState.Exited, exitCode); } /// @@ -171,19 +196,79 @@ public sealed class LauncherProcessSupervisor : IDisposable /// — without this guard, "Running" would silently resurrect a /// process that has already reported its exit. /// - private void SetState(LauncherSessionState state) + private void SetState(LauncherSessionState state, int? exitCode = null) { + bool publish; lock (_gate) { - if (State == LauncherSessionState.Exited) + if (_state == LauncherSessionState.Exited) { return; } - State = state; + _state = state; + if (state == LauncherSessionState.Exited) + { + _exitCode = exitCode; + } + + _pendingStateChanges.Enqueue(state); + publish = !_publishingStateChanges; + if (publish) + { + _publishingStateChanges = true; + } } - StateChanged?.Invoke(this, state); + if (publish) + { + PublishPendingStateChanges(); + } + } + + /// + /// Drains state notifications through one publisher. Transition storage + /// stays under , but user callbacks run outside it so + /// they may re-enter the supervisor or wait for another thread reading + /// state without deadlocking. A concurrent/re-entrant transition queues + /// behind the notification already in flight, preserving storage order in + /// the externally observed event stream. + /// + private void PublishPendingStateChanges() + { + Exception? firstException = null; + while (true) + { + LauncherSessionState state; + lock (_gate) + { + if (_pendingStateChanges.Count == 0) + { + _publishingStateChanges = false; + break; + } + + state = _pendingStateChanges.Dequeue(); + } + + try + { + StateChanged?.Invoke(this, state); + } + catch (Exception ex) + { + // Preserve the previous propagation behavior, but finish + // publishing any transition already committed concurrently + // (especially terminal Exited) before rethrowing the first + // observer failure to the initiating caller. + firstException ??= ex; + } + } + + if (firstException is not null) + { + ExceptionDispatchInfo.Capture(firstException).Throw(); + } } public void Dispose() diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs index 26f3957e..25d30787 100644 --- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -19,6 +19,8 @@ namespace AcDream.Launcher.Core.Profiles; public sealed class LauncherProfileStore { internal const int CurrentVersion = 1; + internal const UnixFileMode OwnerOnlyFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; private static readonly JsonSerializerOptions SerializerOptions = new() { @@ -115,14 +117,12 @@ public sealed class LauncherProfileStore /// /// Persists to via a /// write-then-atomic-rename so a crash mid-write never leaves a - /// truncated credentials file. On Linux, the temp file is chmod'd to - /// owner read/write (0600) immediately after creation — BEFORE any - /// plaintext credential is serialized into it — so there is no window - /// where the temp file carries the process umask's (potentially - /// world/group-readable) default permissions while holding a - /// password; the final path gets the same restriction after the - /// rename (Campaign LA's plaintext-credential decision, spec §5, - /// decisions log; the temp-file window itself is review finding F4). + /// truncated credentials file. On Linux, the temp file is created + /// atomically with owner read/write (0600) as its requested creation + /// mode — before its path is observable and before any plaintext + /// credential is serialized into it. The final path retains that mode + /// through the rename (Campaign LA's plaintext-credential decision, + /// spec §5, decisions log). /// A failure between temp-file creation and the rename deletes the /// stale temp file rather than leaving it behind. /// @@ -135,15 +135,18 @@ public sealed class LauncherProfileStore } string tempPath = FilePath + ".tmp"; + DeleteStaleTempFile(tempPath); try { - using (FileStream stream = File.Create(tempPath)) + using (FileStream stream = CreateCredentialTempFile(tempPath)) { if (OperatingSystem.IsLinux()) { - File.SetUnixFileMode( - tempPath, - UnixFileMode.UserRead | UnixFileMode.UserWrite); + // UnixCreateMode is subject to the process umask. It + // guarantees the file is never created with group/other + // access; normalize the owner bits while the still-empty + // file is open so the persisted contract is exactly 0600. + File.SetUnixFileMode(tempPath, OwnerOnlyFileMode); } JsonSerializer.Serialize(stream, Document, SerializerOptions); @@ -159,12 +162,39 @@ public sealed class LauncherProfileStore if (OperatingSystem.IsLinux()) { - File.SetUnixFileMode( - FilePath, - UnixFileMode.UserRead | UnixFileMode.UserWrite); + File.SetUnixFileMode(FilePath, OwnerOnlyFileMode); } } + /// + /// Builds the exact options used for the plaintext-credential temp + /// file. makes creation atomic and + /// refuses to follow an existing stale or raced path. On Linux, + /// supplies 0600 to + /// the OS create operation itself, eliminating the observable + /// create-then-chmod window. Windows leaves UnixCreateMode unset and + /// therefore retains its normal user-profile ACL behavior. + /// + internal static FileStreamOptions CreateCredentialTempFileOptions() + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + }; + + if (OperatingSystem.IsLinux()) + { + options.UnixCreateMode = OwnerOnlyFileMode; + } + + return options; + } + + internal static FileStream CreateCredentialTempFile(string tempPath) => + new(tempPath, CreateCredentialTempFileOptions()); + private static void DeleteStaleTempFile(string tempPath) { try diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index b4471e36..c090b6e3 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -69,9 +69,9 @@ public sealed record ExitedStatusEvent : StatusEvent } /// -/// A well-formed status line whose e value (or overall envelope -/// shape) this reader does not recognize. The tailer never throws on an -/// unrecognized event — an older launcher reading a newer host's stream +/// A JSON-object status line whose non-empty string e value this +/// reader does not recognize. The tailer never throws on an unrecognized +/// event — an older launcher reading a newer host's stream /// degrades to seeing rows instead of /// crashing. /// @@ -81,10 +81,10 @@ public sealed record UnknownStatusEvent : StatusEvent } /// -/// A status line whose e value IS one of the recognized event -/// names, but whose payload does not match that event's expected shape -/// (a missing required field, or a field present with the wrong JSON -/// kind). Distinguished from (Campaign +/// A complete JSON value that is not an object, an object without a +/// usable event name, or a known event whose pinned v1 envelope/payload +/// does not match its expected shape. Distinguished from +/// (Campaign /// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older /// host sent an event I've never heard of" apart from "a host I recognize /// sent me garbage for an event I do know" — the two cases call for diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index 112ddfe5..f6811a60 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -11,13 +11,13 @@ namespace AcDream.Launcher.Core.Status; /// "accountName":"...","slotCount":6,"characters":[...]}. /// /// -/// Never throws: a null/blank/malformed-JSON line, an unrecognized -/// e value, or a recognized e whose payload doesn't match -/// that event's expected shape, all degrade to a typed event -/// ( or -/// — see each type's docs) rather than an exception — a launcher must -/// keep tailing a session's status stream even against a host running a -/// newer/older wire version, or a host that briefly writes a torn line. +/// Never throws: a null/blank/malformed-JSON line, a complete JSON value +/// with a non-object root, an unrecognized e value, or a recognized +/// e whose envelope/payload does not match the pinned v1 shape all +/// degrade to a typed event ( or +/// ) rather than an exception. A launcher +/// must keep tailing a session's status stream even against a host running a +/// newer/older wire version or a host that writes a bad line. /// /// public static class StatusEventParser @@ -26,10 +26,6 @@ public static class StatusEventParser { if (string.IsNullOrWhiteSpace(line)) { - // Campaign LA plan §LA3 review finding F7: a blank/whitespace - // line is a normal "nothing complete here yet" degrade, not a - // caller error — the old ArgumentException.ThrowIfNullOrWhiteSpace - // guard ran BEFORE the try/catch below and escaped uncaught. return UnknownEvent(line ?? string.Empty); } @@ -46,14 +42,51 @@ public static class StatusEventParser using (document) { JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return MalformedEvent( + v: 0, + e: string.Empty, + t: default, + sessionId: string.Empty, + "status event root is not a JSON object."); + } - int v = GetInt32OrDefault(root, "v"); - string e = GetStringOrDefault(root, "e"); - DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t"); - string sessionId = GetStringOrDefault(root, "sessionId"); + if (!TryGetEventName(root, out string e, out string eventNameError)) + { + return MalformedEvent( + GetInt32OrDefault(root, "v"), + e, + GetDateTimeOffsetOrDefault(root, "t"), + GetStringOrDefault(root, "sessionId"), + eventNameError); + } + + // A genuinely unknown event name is the forward-compatibility + // case. Retain its best-effort envelope and raw JSON without + // imposing this launcher's known-event envelope/payload schema. + if (!IsKnownEventName(e)) + { + return new UnknownStatusEvent + { + V = GetInt32OrDefault(root, "v"), + E = e, + T = GetDateTimeOffsetOrDefault(root, "t"), + SessionId = GetStringOrDefault(root, "sessionId"), + RawJson = line, + }; + } try { + // The pinned v1 envelope applies to every known event, + // including payload-free started/connected rows. Defaulting + // malformed fields would turn corrupt or cross-version input + // into an apparently valid typed event. + int v = RequireVersionOne(root); + DateTimeOffset t = RequireUtcTimestamp(root, "t"); + string sessionId = RequireNonEmptyString(root, "sessionId"); + return e switch { "started" => @@ -72,40 +105,62 @@ public static class StatusEventParser ParseDisconnected(root, v, e, t, sessionId), "exited" => ParseExited(root, v, e, t, sessionId), - _ => - new UnknownStatusEvent - { - V = v, - E = e, - T = t, - SessionId = sessionId, - RawJson = line, - }, + _ => throw new InvalidOperationException("known event dispatch is incomplete."), }; } catch (Exception ex) when (ex is FormatException or InvalidOperationException) { - // FormatException: a Require* helper found a missing - // field or a field of the wrong JSON kind (e.g. - // "secondsGreyedOut": true"). InvalidOperationException: - // a JsonElement API call (EnumerateArray, TryGetProperty) - // against an element of the wrong ValueKind (e.g. - // "characters" present but not an array). Both mean `e` - // WAS recognized but its payload wasn't — distinguished - // from UnknownStatusEvent (Campaign LA plan §LA3 review - // finding F12). - return new MalformedStatusEvent - { - V = v, - E = e, - T = t, - SessionId = sessionId, - Error = ex.Message, - }; + return MalformedEvent( + GetInt32OrDefault(root, "v"), + e, + GetDateTimeOffsetOrDefault(root, "t"), + GetStringOrDefault(root, "sessionId"), + ex.Message); } } } + private static bool IsKnownEventName(string eventName) => + eventName is + "started" or + "connected" or + "characterList" or + "enteredWorld" or + "pluginLoaded" or + "pluginFailed" or + "disconnected" or + "exited"; + + private static bool TryGetEventName( + JsonElement root, + out string eventName, + out string error) + { + if (!root.TryGetProperty("e", out JsonElement element)) + { + eventName = string.Empty; + error = "status event is missing 'e'."; + return false; + } + + if (element.ValueKind != JsonValueKind.String) + { + eventName = string.Empty; + error = "status event field 'e' is not a string."; + return false; + } + + eventName = element.GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(eventName)) + { + error = "status event field 'e' is empty."; + return false; + } + + error = string.Empty; + return true; + } + private static UnknownStatusEvent UnknownEvent(string rawLine) => new() { @@ -116,6 +171,21 @@ public static class StatusEventParser RawJson = rawLine, }; + private static MalformedStatusEvent MalformedEvent( + int v, + string e, + DateTimeOffset t, + string sessionId, + string error) => + new() + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Error = error, + }; + private static StatusEvent ParseCharacterList( JsonElement root, int v, @@ -244,11 +314,8 @@ public static class StatusEventParser string name) => root.TryGetProperty(name, out JsonElement element) && element.ValueKind == JsonValueKind.String - && DateTimeOffset.TryParse( - element.GetString(), - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, - out DateTimeOffset value) + && element.TryGetDateTimeOffset(out DateTimeOffset value) + && value.Offset == TimeSpan.Zero ? value : default; @@ -273,11 +340,52 @@ public static class StatusEventParser : throw new FormatException($"status event field '{name}' is not an integer."); } + private static int RequireVersionOne(JsonElement root) + { + int version = RequireInt32(root, "v"); + return version == 1 + ? version + : throw new FormatException( + $"status event version is {version}; expected 1."); + } + + private static string RequireNonEmptyString(JsonElement root, string name) + { + string value = RequireString(root, name); + return !string.IsNullOrWhiteSpace(value) + ? value + : throw new FormatException($"status event field '{name}' is empty."); + } + + private static DateTimeOffset RequireUtcTimestamp(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + if (element.ValueKind != JsonValueKind.String) + { + throw new FormatException( + $"status event field '{name}' is not an ISO-8601 UTC string."); + } + + string text = element.GetString() ?? string.Empty; + bool hasExplicitUtcOffset = text.EndsWith('Z') + || text.EndsWith("+00:00", StringComparison.Ordinal); + if (!hasExplicitUtcOffset + || !element.TryGetDateTimeOffset(out DateTimeOffset value) + || value.Offset != TimeSpan.Zero) + { + throw new FormatException( + $"status event field '{name}' is not an ISO-8601 UTC timestamp."); + } + + return value; + } + private static uint RequireUInt32(JsonElement root, string name) { JsonElement element = RequireProperty(root, name); return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value) ? value - : throw new FormatException($"status event field '{name}' is not an unsigned integer."); + : throw new FormatException( + $"status event field '{name}' is not an unsigned integer."); } } diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index bab8b6c2..4ec1431e 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Threading; using AcDream.Launcher.Core.Launching; @@ -235,6 +236,95 @@ public sealed class LauncherProcessSupervisorTests states); } + [Fact] + public async Task ConcurrentRunningAndExitedPublicationsRemainMonotonicAndInOrder() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + using var runningPublicationEntered = new ManualResetEventSlim(false); + using var releaseRunningPublication = new ManualResetEventSlim(false); + var states = new ConcurrentQueue(); + + supervisor.StateChanged += (_, state) => + { + if (state == LauncherSessionState.Running) + { + runningPublicationEntered.Set(); + Assert.True( + releaseRunningPublication.Wait(TimeSpan.FromSeconds(5)), + "test did not release the Running publication barrier"); + } + + states.Enqueue(state); + }; + + Task startTask = Task.Run(() => supervisor.Start(Spec(), "pw")); + try + { + Assert.True( + runningPublicationEntered.Wait(TimeSpan.FromSeconds(5)), + "Running publication did not reach the test barrier"); + + // Commit Exited while Running's observer is deliberately + // paused. Storage reaches the terminal state immediately, but + // publication must queue behind the earlier Running event. + factory.LastCreated!.ExitForTest(17); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + } + finally + { + releaseRunningPublication.Set(); + } + + await startTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal( + [ + LauncherSessionState.Starting, + LauncherSessionState.Running, + LauncherSessionState.Exited, + ], + states); + Assert.Equal(17, supervisor.ExitCode); + } + + [Fact] + public void StateChangedPublicationAllowsCrossThreadReadsAndReentrantExit() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + var states = new List(); + + supervisor.StateChanged += (_, state) => + { + states.Add(state); + if (state != LauncherSessionState.Running) + { + return; + } + + // A publisher that invokes callbacks while holding the state + // gate deadlocks this cross-thread read. The callback also + // raises Exited re-entrantly; it must queue after Running rather + // than recurse out of order or deadlock. + Task readTask = Task.Run(() => supervisor.State); + Assert.True(readTask.Wait(TimeSpan.FromSeconds(5))); + Assert.Equal(LauncherSessionState.Running, readTask.Result); + factory.LastCreated!.ExitForTest(23); + }; + + supervisor.Start(Spec(), "pw"); + + Assert.Equal( + [ + LauncherSessionState.Starting, + LauncherSessionState.Running, + LauncherSessionState.Exited, + ], + states); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + Assert.Equal(23, supervisor.ExitCode); + } + [Fact] public void LauncherProcessSpecCarriesNoCredentialLikeMember() { @@ -358,12 +448,22 @@ public sealed class LauncherProcessSupervisorTests { // Simulates a child that dies synchronously from inside // Process.Start() itself (review finding F9's race). - HasExited = true; - ExitCode = 0; - Exited?.Invoke(this, EventArgs.Empty); + ExitForTest(0); } } + public void ExitForTest(int exitCode) + { + if (HasExited) + { + return; + } + + HasExited = true; + ExitCode = exitCode; + Exited?.Invoke(this, EventArgs.Empty); + } + public bool TryRequestGracefulStop() { TryRequestGracefulStopCallCount++; @@ -382,9 +482,7 @@ public sealed class LauncherProcessSupervisorTests { KillCallCount++; CallOrder.Add("kill"); - HasExited = true; - ExitCode = -1; - Exited?.Invoke(this, EventArgs.Empty); + ExitForTest(-1); } public bool WaitForExit(TimeSpan timeout) @@ -392,9 +490,7 @@ public sealed class LauncherProcessSupervisorTests if (!exitsWithinStopTimeout) return false; - HasExited = true; - ExitCode = 0; - Exited?.Invoke(this, EventArgs.Empty); + ExitForTest(0); return true; } diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs index 957fa2cb..8d83c88d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs @@ -1,4 +1,3 @@ -using System.Threading; using AcDream.Launcher.Core.Profiles; namespace AcDream.Launcher.Core.Tests.Profiles; @@ -287,68 +286,42 @@ public sealed class LauncherProfileStoreTests : IDisposable } [Fact] - public void SaveNeverLeavesTheTempFileWorldOrGroupReadableDuringTheWrite() + public void TempCredentialCreationOptionsRequestAtomicPlatformCorrectCreation() { - // Review finding F4: the temp file used to be created with the - // process's default umask and only chmod'd AFTER the atomic - // rename, leaving a window where the plaintext-credential temp - // file could be world/group-readable. The fix chmods the temp - // file immediately after creation, BEFORE any content (including - // the password) is serialized into it. A large document makes - // the write take long enough for a concurrent poller to have a - // real chance at observing a regression. + FileStreamOptions options = + LauncherProfileStore.CreateCredentialTempFileOptions(); + Assert.Equal(FileMode.CreateNew, options.Mode); + Assert.Equal(FileAccess.Write, options.Access); + Assert.Equal(FileShare.None, options.Share); + + if (OperatingSystem.IsLinux()) + { + Assert.Equal( + LauncherProfileStore.OwnerOnlyFileMode, + options.UnixCreateMode); + } + else + { + Assert.Null(options.UnixCreateMode); + } + } + + [Fact] + public void TempCredentialFileIsOwnerOnlyFromItsFirstObservableLinuxState() + { + // Deterministic proof of the exact production create path: inspect + // the file while the CreateNew handle is still open, before any + // serialization or post-create chmod can occur. This replaces the + // old timing-only poller, which could miss the vulnerable window. if (!OperatingSystem.IsLinux()) return; - var store = new LauncherProfileStore(_filePath); - store.Load(); - store.AddServer("Local ACE", "127.0.0.1", 9000); - for (int i = 0; i < 300; i++) - { - store.AddAccount("Local ACE", $"account{i}", new string('x', 4096)); - } - string tempPath = _filePath + ".tmp"; - bool observedLooseMode = false; - bool stop = false; - var poller = new Thread(() => - { - while (!Volatile.Read(ref stop)) - { - if (File.Exists(tempPath)) - { - try - { - // The platform-compat analyzer can't see the - // enclosing test method's `OperatingSystem.IsLinux()` - // guard across this lambda boundary; suppressed - // rather than restructured, since the guard is - // real and this whole method is a no-op off Linux. -#pragma warning disable CA1416 - UnixFileMode mode = File.GetUnixFileMode(tempPath); -#pragma warning restore CA1416 - if ((mode & ~(UnixFileMode.UserRead | UnixFileMode.UserWrite)) != 0) - { - observedLooseMode = true; - } - } - catch (IOException) - { - // Renamed/deleted between the Exists check and - // GetUnixFileMode — not a finding, just keep - // polling. - } - } - } - }); - poller.Start(); + using FileStream stream = LauncherProfileStore.CreateCredentialTempFile(tempPath); - store.Save(); - - Volatile.Write(ref stop, true); - poller.Join(); - - Assert.False(observedLooseMode); + Assert.Equal( + LauncherProfileStore.OwnerOnlyFileMode, + File.GetUnixFileMode(tempPath)); } [Fact] diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index d28b642b..47f5e118 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -112,6 +112,19 @@ public sealed class StatusEventParserTests Assert.IsType(e); } + [Theory] + [InlineData("[]")] + [InlineData("null")] + [InlineData("42")] + [InlineData("\"text\"")] + public void CompleteJsonWithANonObjectRootSurfacesAsMalformedEvent(string line) + { + var e = StatusEventParser.Parse(line); + + var malformed = Assert.IsType(e); + Assert.Contains("root", malformed.Error, StringComparison.OrdinalIgnoreCase); + } + [Theory] [InlineData("")] [InlineData(" ")] @@ -136,6 +149,33 @@ public sealed class StatusEventParserTests Assert.IsType(e); } + [Theory] + [InlineData("{\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":\"1\",\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":2,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":1,\"e\":\"connected\",\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":1,\"e\":\"started\",\"t\":42,\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"not-a-time\",\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00+02:00\",\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\"}")] + [InlineData("{\"v\":1,\"e\":\"started\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":42}")] + [InlineData("{\"v\":1,\"e\":\"connected\",\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"\"}")] + public void PayloadFreeKnownEventsRequireTheFullPinnedV1Envelope(string line) + { + var e = StatusEventParser.Parse(line); + + var malformed = Assert.IsType(e); + Assert.False(string.IsNullOrWhiteSpace(malformed.Error)); + } + + [Theory] + [InlineData("{\"v\":1,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")] + [InlineData("{\"v\":1,\"e\":42,\"t\":\"2026-08-14T12:00:00Z\",\"sessionId\":\"s1\"}")] + public void MissingOrWrongKindEventNameSurfacesAsMalformedEvent(string line) + { + Assert.IsType(StatusEventParser.Parse(line)); + } + [Fact] public void KnownEValueWithMissingRequiredFieldSurfacesAsMalformedEventRatherThanThrowing() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs index b619fe25..d2ad011c 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -61,6 +61,21 @@ public sealed class StatusFileTailerTests : IDisposable Assert.IsType(events[1]); } + [Fact] + public void ContinuesPastCompleteNonObjectJsonValuesToTheFollowingValidLine() + { + AppendShared( + "[]\nnull\n42\n\"text\"\n" + + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(5, events.Count); + Assert.All(events.Take(4), e => Assert.IsType(e)); + Assert.IsType(events[4]); + } + [Fact] public void TolerateAPartialLastLineAndCompletesItOnALaterPoll() { From 8a03a25fc32914d5db138def9021e861341df1a7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:16:13 +0200 Subject: [PATCH 025/138] test(launcher): Campaign LA enforce composer-host contract --- .github/workflows/headless-portability.yml | 8 +++ .../AcDream.App.Tests.csproj | 6 ++ .../SessionConfigurationSharedFixtureTests.cs | 38 ++++++++++++ .../AcDream.Headless.Tests.csproj | 6 ++ .../SessionConfigurationSharedFixtureTests.cs | 39 +++++++++++++ .../LauncherCoreSessionConfigFixture.cs | 58 +++++++++++++++++++ 6 files changed, 155 insertions(+) create mode 100644 tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index a24ae829..6facc5ed 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -6,6 +6,7 @@ on: - ".github/workflows/headless-portability.yml" - "AcDream.slnx" - "src/AcDream.Platform/**" + - "src/AcDream.Launcher.Core/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -15,6 +16,7 @@ on: - "src/AcDream.App/**" - "src/AcDream.UI.Abstractions/**" - "tests/AcDream.Platform.Tests/**" + - "tests/AcDream.Launcher.Core.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -22,6 +24,7 @@ on: - "tests/AcDream.Headless.Tests/**" - "tests/AcDream.App.Tests/**" - "tests/AcDream.UI.Abstractions.Tests/**" + - "tests/Fixtures/campaign-la/**" - "tools/ShaderCompiler/**" - "tools/compile-shaders.ps1" push: @@ -29,6 +32,7 @@ on: - ".github/workflows/headless-portability.yml" - "AcDream.slnx" - "src/AcDream.Platform/**" + - "src/AcDream.Launcher.Core/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -38,6 +42,7 @@ on: - "src/AcDream.App/**" - "src/AcDream.UI.Abstractions/**" - "tests/AcDream.Platform.Tests/**" + - "tests/AcDream.Launcher.Core.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -45,6 +50,7 @@ on: - "tests/AcDream.Headless.Tests/**" - "tests/AcDream.App.Tests/**" - "tests/AcDream.UI.Abstractions.Tests/**" + - "tests/Fixtures/campaign-la/**" - "tools/ShaderCompiler/**" - "tools/compile-shaders.ps1" workflow_dispatch: @@ -83,6 +89,7 @@ jobs: run: | $projects = @( "src/AcDream.Platform/AcDream.Platform.csproj", + "src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj", "src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj", "src/AcDream.Core/AcDream.Core.csproj", "src/AcDream.Core.Net/AcDream.Core.Net.csproj", @@ -104,6 +111,7 @@ jobs: run: | $projects = @( "tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj", + "tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj", "tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj", "tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj", "tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj", diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 186d903d..6760881b 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -22,6 +22,12 @@ + + + + + diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs index 1760946b..c37ce9fe 100644 --- a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using AcDream.App.Configuration; +using AcDream.Tests.Fixtures.CampaignLa; namespace AcDream.App.Tests.Configuration; @@ -18,6 +19,43 @@ namespace AcDream.App.Tests.Configuration; /// public sealed class SessionConfigurationSharedFixtureTests { + [Fact] + public void AppReaderAcceptsLauncherCoreComposerDocument() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.Compose()); + + (SessionConfiguration configuration, SessionDescriptor session) = + SessionConfigurationLoader.Load(file.Path); + + Assert.Equal("composer-contract", session.Id); + Assert.Equal("composer.example", session.Endpoint.Host); + Assert.Equal(9010, session.Endpoint.Port); + Assert.Equal("composer-account", session.Account); + Assert.Equal(0x50000001u, session.Character?.Id); + Assert.Equal("idle", session.Policy?.Id); + Assert.Equal( + SessionCredentialProviderKind.StandardInput, + session.Credential.Provider); + Assert.Equal("session", session.Credential.Reference); + Assert.Equal("composer-dats", configuration.Process?.Content?.DatDirectory); + Assert.Equal( + "composer-dats/acdream.pak", + configuration.Process?.Content?.PreparedAssetPath); + Assert.Equal(["ComposerPlugin"], session.Plugins); + Assert.Equal(["/composer command"], session.LoginCommands); + Assert.Equal(625, session.LoginCommandDelayMs); + string statusFile = Assert.IsType(session.StatusFile); + Assert.EndsWith( + Path.Combine("composer-contract", "status.jsonl"), + statusFile, + StringComparison.Ordinal); + Assert.DoesNotContain( + LauncherCoreSessionConfigFixture.Password, + File.ReadAllText(file.Path), + StringComparison.Ordinal); + } + [Fact] public void AppReaderAcceptsTheProductionShapedSharedFixture() { diff --git a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj index 8a17a145..9591a359 100644 --- a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj +++ b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj @@ -21,5 +21,11 @@ + + + + + diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs index 20a86697..5a8a3aa1 100644 --- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using AcDream.Headless.Configuration; +using AcDream.Tests.Fixtures.CampaignLa; namespace AcDream.Headless.Tests; @@ -17,6 +18,44 @@ namespace AcDream.Headless.Tests; /// public sealed class SessionConfigurationSharedFixtureTests { + [Fact] + public void HeadlessReaderAcceptsLauncherCoreComposerDocument() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.Compose()); + + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(file.Path); + + HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!; + Assert.Equal("composer-contract", session.Id); + Assert.Equal("composer.example", session.Endpoint.Host); + Assert.Equal(9010, session.Endpoint.Port); + Assert.Equal("composer-account", session.Account); + Assert.Equal(0x50000001u, session.Character.Id); + Assert.Equal("idle", session.Policy.Id); + Assert.Equal( + HeadlessCredentialProviderKind.StandardInput, + session.Credential.Provider); + Assert.Equal("session", session.Credential.Reference); + Assert.Equal("composer-dats", configuration.Process.Content?.DatDirectory); + Assert.Equal( + "composer-dats/acdream.pak", + configuration.Process.Content?.PreparedAssetPath); + Assert.Equal(["ComposerPlugin"], session.Plugins); + Assert.Equal(["/composer command"], session.LoginCommands); + Assert.Equal(625, session.LoginCommandDelayMs); + string statusFile = Assert.IsType(session.StatusFile); + Assert.EndsWith( + Path.Combine("composer-contract", "status.jsonl"), + statusFile, + StringComparison.Ordinal); + Assert.DoesNotContain( + LauncherCoreSessionConfigFixture.Password, + File.ReadAllText(file.Path), + StringComparison.Ordinal); + } + [Fact] public void HeadlessReaderAcceptsTheProductionShapedSharedFixture() { diff --git a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs new file mode 100644 index 00000000..71b6581e --- /dev/null +++ b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs @@ -0,0 +1,58 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; +using AcDream.Platform; + +namespace AcDream.Tests.Fixtures.CampaignLa; + +/// +/// Produces one real Launcher.Core session document that is compiled into +/// both host test suites. Keeping composition in one linked fixture makes the +/// LA1/LA3 anti-drift gate prove that App and Headless accept the identical +/// composer output rather than two hand-maintained lookalikes. +/// +internal static class LauncherCoreSessionConfigFixture +{ + internal const string Password = "must-not-be-serialized"; + + internal static string Compose() + { + var server = new ServerProfile + { + Name = "Composer Server", + Host = "composer.example", + Port = 9010, + }; + var account = new AccountProfile + { + Account = "composer-account", + Password = Password, + }; + var character = new CharacterProfile + { + Name = "Composer Character", + Id = "0x50000001", + LaunchMode = LaunchMode.Headless, + Plugins = ["ComposerPlugin"], + LoginCommands = ["/composer command"], + }; + var install = new LauncherInstallRecord( + "composer-dats", + "composer-dats/acdream.pak"); + var paths = new ApplicationPathSet( + Path.Combine(Path.GetTempPath(), "composer-config"), + Path.Combine(Path.GetTempPath(), "composer-data"), + Path.Combine(Path.GetTempPath(), "composer-cache"), + LegacyConfigDirectory: null); + + ComposedSessionConfig composed = SessionConfigComposer.Compose( + server, + account, + character, + install, + paths, + "composer-contract", + loginCommandDelayMs: 625); + + return SessionConfigComposer.Serialize(composed.Document); + } +} From 3313577dccff5b8a8988eac7c724add3d3eae206 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:17:56 +0200 Subject: [PATCH 026/138] docs(launcher): Campaign LA close LA1 and LA3 --- CLAUDE.md | 4 ++++ docs/plans/2026-04-11-roadmap.md | 5 +++++ docs/plans/2026-08-14-launcher-campaign.md | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cbd115a4..2c9973e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,6 +245,10 @@ NO 3D preview (chargen-only machinery); UI Studio no longer exists (deleted at Campaign V — ignore stale memory/docs claims otherwise); App `Program.cs` has no subcommand dispatch (the `--session-config` flag is additive). +LA0, LA1, LA3, and LA7a are review-closed. The launcher composer is now +compiled into both host test suites, and Launcher.Core runs in the portable +Windows/Ubuntu CI closure. LA2's probe/idle review fixes are the active edge; +LA4/LA5/LA7b follow in parallel after that integration. **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 84534cdb..994b83cc 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -103,6 +103,11 @@ a future campaign). Spec: [`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); plan + ledger: [`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). +LA0, LA1, LA3, and LA7a are review-closed: the portable path boundary, +failure-isolated launch/status contract, BCL-only launcher core, shared +composer-to-both-host-loader anti-drift gate, and character wire messages are +landed. LA2 probe/idle review fixes are the active recovery edge before the +Avalonia/plugin/selection parallel wave. **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index a5bc0142..1e3270b9 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -487,9 +487,9 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | -| LA1 | fix round complete; narrow Opus re-review pending | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (WIP), `d511e4c3` (fix round) | Initial review FIX FIRST; findings F1–F8 addressed; narrow re-review pending | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Production-shaped shared fixture values asserted by BOTH host readers; known mid-play silent-wire-drop limitation recorded above. | +| LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. | | LA2 | — | | | | -| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | +| LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. | | LA4 | — | | | | | LA5 | — | | | | | LA6 | — | | | | From 1c5e66c05b73e9e0e41ae7a5549a568ba374c6e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:20:02 +0200 Subject: [PATCH 027/138] fix(launcher): Campaign LA close LA2 review findings --- docs/plans/2026-08-14-launcher-campaign.md | 2 +- .../Configuration/HeadlessConfiguration.cs | 5 +- .../HeadlessConfigurationLoader.cs | 78 ++++++++++-- .../Hosting/HeadlessSessionHost.cs | 59 +++++++-- .../Session/LiveSessionController.cs | 17 ++- .../HeadlessConfigurationLoaderTests.cs | 99 +++++++++++++++ .../HeadlessSessionHostTests.cs | 117 +++++++++++++++--- .../Session/LiveSessionControllerTests.cs | 27 +++- 8 files changed, 346 insertions(+), 58 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index ae9586e1..264a209b 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -478,7 +478,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. |---|---|---|---|---| | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers | -| LA2 | implementation complete; automated gates **GREEN**; Opus dual-lens review pending | `c6019424` + completion (this commit) | pending | Probe is roster-before-selection with graceful pre-world teardown and exit 0; normal play remains strict selector + `idle` policy. Release build green; Runtime 1,632/1,632 and Headless 141/141 on both Windows and Ubuntu/WSL | +| LA2 | review fix round complete; automated gates **GREEN**; narrow re-review pending | `c6019424`, `000ea979` + review fixes (this commit) | Opus dual-lens FIX FIRST; all 3 findings fixed, narrow re-review pending | ProbeComplete now requires a reported roster and is still before selection/EnterWorld; terminal status derives from the actual start outcome and matches failed-start process code 5; conditional config fields distinguish omission from explicit null without weakening strict JSON shape/type checks. Release build green on Windows and Ubuntu/WSL; Runtime 1,632/1,632 and Headless 149/149 on both | | LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | | LA4 | — | | | | | LA5 | — | | | | diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 1ad3a913..fdae1225 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -47,8 +47,9 @@ internal sealed record HeadlessSessionDescriptor public string Account { get; init; } = string.Empty; /// - /// Campaign LA slice LA2: ABSENT () for normal play - /// sessions; for the LA2 probe + /// Campaign LA slice LA2: the JSON field is ABSENT for normal play + /// sessions (explicit JSON null is invalid); + /// for the LA2 probe /// (connect → characterList → graceful disconnect, never EnterWorld) — /// the pinned launch-contract schema's mode field /// (docs/plans/2026-08-14-launcher-campaign.md LA1/LA2). diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 4c83699c..27686c1e 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -97,10 +97,15 @@ internal static class HeadlessConfigurationLoader string fullPath = Path.GetFullPath(path); using FileStream stream = File.OpenRead(fullPath); + using JsonDocument document = JsonDocument.Parse( + stream, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + }); HeadlessConfiguration? configuration = - JsonSerializer.Deserialize( - stream, - Options); + document.RootElement.Deserialize(Options); if (configuration is null) { @@ -123,9 +128,12 @@ internal static class HeadlessConfigurationLoader ValidateContent(configuration.Process?.Content); + JsonElement sessionsElement = + document.RootElement.GetProperty("sessions"); var sessionIds = new HashSet(StringComparer.Ordinal); var credentialReferences = new HashSet( StringComparer.Ordinal); + int sessionIndex = 0; foreach (HeadlessSessionDescriptor? session in configuration.Sessions) { if (session is null @@ -141,7 +149,7 @@ internal static class HeadlessConfigurationLoader $"Duplicate session id '{session.Id}'."); } - ValidateSession(session); + ValidateSession(session, sessionsElement[sessionIndex]); string credentialKey = $"{session.Credential.Provider}:{session.Credential.Reference}"; if (!credentialReferences.Add(credentialKey)) @@ -149,6 +157,7 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Credential reference for session '{session.Id}' is already in use."); } + sessionIndex++; } return configuration; @@ -166,7 +175,9 @@ internal static class HeadlessConfigurationLoader } } - private static void ValidateSession(HeadlessSessionDescriptor session) + private static void ValidateSession( + HeadlessSessionDescriptor session, + JsonElement sessionElement) { if (session.Endpoint is null || string.IsNullOrWhiteSpace(session.Endpoint.Host) @@ -182,7 +193,7 @@ internal static class HeadlessConfigurationLoader $"Session '{session.Id}' requires a non-empty account."); } - ValidateModeShape(session); + ValidateModeShape(session, sessionElement); if (session.Credential is null || string.IsNullOrWhiteSpace(session.Credential.Reference)) @@ -214,17 +225,37 @@ internal static class HeadlessConfigurationLoader /// that also declares a selector or a policy, rather than silently /// ignoring them. /// - private static void ValidateModeShape(HeadlessSessionDescriptor session) + private static void ValidateModeShape( + HeadlessSessionDescriptor session, + JsonElement sessionElement) { + bool hasMode = sessionElement.TryGetProperty( + "mode", + out JsonElement modeElement); + bool hasCharacter = sessionElement.TryGetProperty( + "character", + out JsonElement characterElement); + bool hasPolicy = sessionElement.TryGetProperty( + "policy", + out JsonElement policyElement); + + RejectExplicitNull(session.Id, "mode", hasMode, modeElement); + RejectExplicitNull( + session.Id, + "character", + hasCharacter, + characterElement); + RejectExplicitNull(session.Id, "policy", hasPolicy, policyElement); + if (session.Mode == HeadlessSessionMode.Probe) { - if (session.Character is not null) + if (hasCharacter) { throw new HeadlessConfigurationException( $"Session '{session.Id}' has mode \"probe\" and must omit " + "'character' — a probe never selects a character."); } - if (session.Policy is not null) + if (hasPolicy) { throw new HeadlessConfigurationException( $"Session '{session.Id}' has mode \"probe\" and must omit " @@ -233,6 +264,12 @@ internal static class HeadlessConfigurationLoader return; } + if (hasMode) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' is normal play and must omit 'mode'."); + } + if (session.Character is null) { throw new HeadlessConfigurationException( @@ -259,6 +296,29 @@ internal static class HeadlessConfigurationLoader } } + /// + /// Campaign LA LA2 review fix: the pinned launch contract distinguishes + /// an omitted conditional field from a field explicitly authored as JSON + /// null. Nullable CLR properties cannot retain that distinction, so + /// validation also consumes the already-parsed strict JSON shape. The + /// typed serializer still owns unknown-member, enum, and value-type + /// enforcement; this check adds presence semantics without weakening any + /// of those gates. + /// + private static void RejectExplicitNull( + string sessionId, + string propertyName, + bool isPresent, + JsonElement value) + { + if (isPresent && value.ValueKind == JsonValueKind.Null) + { + throw new HeadlessConfigurationException( + $"Session '{sessionId}' field '{propertyName}' cannot be null; " + + "supply a value when allowed or omit the field."); + } + } + /// /// Campaign LA slice LA1: validates the four new optional per-session /// fields shared with the App session-config reader (see diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 0a17e8f5..392d6166 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -120,6 +120,15 @@ internal sealed class HeadlessSessionHost : IDisposable /// rework of the shared-stdout diagnostics writer. /// private readonly SessionStatusWriter _statusWriter; + /// + /// Campaign LA LA2 review fix: the actual result returned by the process + /// start attempt. A configured probe mode is only intent; terminal status + /// may claim reason:"probe" after this records + /// . Any other + /// non-connected result maps to the same connection-error code returned by + /// . + /// + private RuntimeSessionStartStatus? _startOutcome; /// Guards 's disconnected status event /// so a Stop() on a session that never actually reached Connected (e.g. /// disposing a fresh, never-started host) does not report a spurious @@ -465,7 +474,10 @@ internal sealed class HeadlessSessionHost : IDisposable // Campaign LA slice LA1: "started" = session host start — the // earliest point this session actually attempts to connect. _statusWriter.Started(_descriptor.Id); - return Commands.Session.Start(Runtime.Generation); + RuntimeSessionStartResult result = + Commands.Session.Start(Runtime.Generation); + _startOutcome = result.Status; + return result; } internal RuntimeSessionStartResult Reconnect() => @@ -647,18 +659,16 @@ internal sealed class HeadlessSessionHost : IDisposable _stoppedGeneration); // Campaign LA slice LA1: "exited" = terminal — the sole // point every disposal path (graceful and post- - // quarantine) converges on. LA2: a probe session that - // never faulted reports reason "probe" here instead of - // "disposed" — the pinned contract's exit event for a - // successful probe. + // quarantine) converges on. LA2: only an actual + // ProbeComplete start outcome reports reason "probe"; + // configured probe intent cannot turn a failed start into + // a successful terminal event. + (int exitCode, string exitReason) = + ResolveTerminalStatus(); _statusWriter.Exited( _descriptor.Id, - _faulted ? 1 : 0, - _faulted - ? "fault" - : _descriptor.Mode == HeadlessSessionMode.Probe - ? "probe" - : "disposed"); + exitCode, + exitReason); _disposeStage++; _disposed = true; break; @@ -669,6 +679,33 @@ internal sealed class HeadlessSessionHost : IDisposable } } + /// + /// Produces the same terminal classification the owning process host uses. + /// Descriptor mode never participates: only an observed ProbeComplete may + /// report a successful probe. The surrounding disposal stage and LA1's + /// terminal/idempotent make this event + /// exact-once even when disposal is retried. + /// + private (int Code, string Reason) ResolveTerminalStatus() + { + if (_faulted) + { + return ( + (int)HeadlessExitCode.RuntimeError, + "runtime-fault"); + } + + return _startOutcome switch + { + RuntimeSessionStartStatus.ProbeComplete => + ((int)HeadlessExitCode.Success, "probe"), + null or RuntimeSessionStartStatus.Connected => + ((int)HeadlessExitCode.Success, "graceful"), + _ => + ((int)HeadlessExitCode.ConnectionError, "connection-error"), + }; + } + private RuntimeSessionStartResult StartCore( RuntimeGenerationToken expectedGeneration, bool reconnect) diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index ee4fdfbb..935de09d 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -656,16 +656,13 @@ public sealed class LiveSessionController } // Campaign LA slice LA2: the probe short-circuit lands here — - // right after the roster report, before TrySelectCharacter ever - // runs — so a probe session never reaches selection, - // ApplySelectedCharacter, or EnterWorld. This mirrors the - // NoCharacters early-exit immediately below (same StopCore - // teardown), the deliberate difference being the returned status - // is a SUCCESS, not a failure. Non-probe callers (options.Probe - // is false by default) fall straight through to the unchanged - // selection/enter path below — byte-identical to pre-LA2 - // behavior. - if (options.Probe) + // only after a real CharacterList was returned and its roster was + // reported, before TrySelectCharacter ever runs. A missing + // CharacterList falls through to the existing NoCharacters + // non-success path below; connectivity by itself is not a + // successful character-roster probe. Non-probe callers continue + // through the unchanged selection/enter path. + if (options.Probe && characters is not null) { Console.WriteLine( "live: probe complete — disconnecting before EnterWorld"); diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs index 6902fccc..25636cb4 100644 --- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -269,6 +269,105 @@ public sealed class HeadlessConfigurationLoaderTests () => HeadlessConfigurationLoader.Load(file.Path)); } + /// + /// Campaign LA LA2 review fix: the pinned contract is presence-aware. + /// Normal play omits mode and supplies character/policy; probe supplies + /// mode and omits character/policy. JSON null is not another spelling of + /// omission for any of those conditional fields. + /// + [Theory] + [InlineData(false, "mode")] + [InlineData(false, "character")] + [InlineData(false, "policy")] + [InlineData(true, "character")] + [InlineData(true, "policy")] + public void ConditionalSessionFieldsRejectExplicitJsonNull( + bool probe, + string nullField) + { + string mode = probe + ? "\"mode\":\"probe\"," + : nullField == "mode" + ? "\"mode\":null," + : string.Empty; + string character = nullField == "character" + ? "\"character\":null," + : probe + ? string.Empty + : "\"character\":{\"index\":0},"; + string policy = nullField == "policy" + ? "\"policy\":null," + : probe + ? string.Empty + : "\"policy\":{\"id\":\"idle\"},"; + using TemporaryConfiguration file = TemporaryConfiguration.Create( + $$""" + { + "version": 1, + "sessions": [ + { + "id": "explicit-null", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + {{mode}} + {{character}} + {{policy}} + "credential": { "provider": "environment", "reference": "PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + $"'{nullField}'", + exception.Message, + StringComparison.Ordinal); + Assert.Contains( + "cannot be null", + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void PresenceAwareValidationKeepsUnmappedMemberRejection() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session( + "bot", + "PASSWORD", + "\"notAContractField\":true"))); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void PresenceAwareValidationKeepsTypedValueRejection() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "wrong-type", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": { "value": "probe" }, + "credential": { "provider": "environment", "reference": "PASSWORD" } + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + [Fact] public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index f568feb2..8ec13904 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -262,6 +262,85 @@ public sealed class HeadlessSessionHostTests Assert.False(cancellation.IsCancellationRequested); } + /// + /// Campaign LA LA2 review fix: configured probe intent is not proof of a + /// completed probe. If the connected session produces no CharacterList, + /// Runtime returns NoCharacters, the process returns ConnectionError, and + /// the sole terminal status event reports that same non-success instead of + /// the former false code-0/reason-probe pair. + /// + [Fact] + public async Task ProbeWithoutRosterReportsTheProcessConnectionErrorExactlyOnce() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-probe-no-roster-{Guid.NewGuid():N}.jsonl"); + try + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + ProbeDescriptor( + provider: HeadlessCredentialProviderKind.StandardInput, + credentialReference: "probe-password", + statusFile: statusPath), + ], + }; + var operations = new FixtureSessionOperations + { + Characters = null, + }; + using var diagnostics = new StringWriter(); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + new System.IO.StringReader( + "probe-password" + Environment.NewLine), + diagnostics, + operations); + + HeadlessExitCode result = await host.RunAsync( + CancellationToken.None); + + Assert.Equal(HeadlessExitCode.ConnectionError, result); + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.Equal(1, operations.DisposedSessionCount); + + host.Dispose(); + host.Dispose(); + + string[] lines = File.ReadAllLines(statusPath); + JsonElement[] events = lines + .Select(static line => + JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + Assert.Equal( + ["started", "connected", "disconnected", "exited"], + events.Select(static item => + item.GetProperty("e").GetString())); + Assert.DoesNotContain( + events, + static item => + item.GetProperty("e").GetString() == "characterList"); + JsonElement exited = Assert.Single( + events, + static item => item.GetProperty("e").GetString() == "exited"); + Assert.Equal( + (int)result, + exited.GetProperty("code").GetInt32()); + Assert.Equal( + "connection-error", + exited.GetProperty("reason").GetString()); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + /// /// Campaign LA slice LA2: a probe session completing must not tear down /// a sibling play session sharing the same process — the process exit @@ -3019,6 +3098,23 @@ public sealed class HeadlessSessionHostTests public int EnterWorldCallCount => Volatile.Read(ref _enterWorldCallCount); public int TickCallCount => Volatile.Read(ref _tickCallCount); + public CharacterList.Parsed? Characters { get; init; } = new( + 0u, + [ + new CharacterList.Character( + 0x50000001u, + "Other", + 0u), + new CharacterList.Character( + 0x50000002u, + "Headless", + 0u), + ], + [], + 11, + "account", + true, + true); public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); @@ -3040,25 +3136,8 @@ public sealed class HeadlessSessionHostTests LastPassword = password; } - public CharacterList.Parsed GetCharacters( - WorldSession session) => - new( - 0u, - [ - new CharacterList.Character( - 0x50000001u, - "Other", - 0u), - new CharacterList.Character( - 0x50000002u, - "Headless", - 0u), - ], - [], - 11, - "account", - true, - true); + public CharacterList.Parsed? GetCharacters( + WorldSession session) => Characters; public void EnterWorld( WorldSession session, diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index b4ff6a25..10b4e18f 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -441,13 +441,13 @@ public sealed class LiveSessionControllerTests } /// - /// The probe short-circuit fires even when the server never returns a - /// CharacterList at all (GetCharacters returns null) — a probe is a - /// connectivity check, not itself a character-selection operation, so it - /// must not fall through to the NoCharacters path. + /// Campaign LA LA2 review fix: ProbeComplete proves a real CharacterList + /// was received and reported, not merely that the socket connected. A + /// missing roster follows the existing NoCharacters non-success path and + /// still drains the exact pre-world teardown transaction. /// [Fact] - public void Start_ProbeWithoutCharacterListStillCompletesGracefully() + public void Start_ProbeWithoutCharacterListIsNonSuccessAndTearsDownGracefully() { var calls = new List(); var operations = new TestOperations(calls) { Characters = null }; @@ -458,10 +458,25 @@ public sealed class LiveSessionControllerTests LiveOptions(probe: true), host); - Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status); + Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status); Assert.Empty(host.Rosters); + Assert.Equal( + [ + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "deactivate", + "detach-events", "dispose-session", "detach-session", "reset", + ], + calls); Assert.Equal(0, operations.EnterWorldCount); Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + + LiveSessionOwnershipSnapshot ownership = controller.CaptureOwnership(); + Assert.Equal(RuntimeTeardownStage.Complete, ownership.LastTeardownStages); + Assert.False(ownership.HasActiveSession); + Assert.False(ownership.HasRetiredSession); + Assert.False(ownership.HasPendingOperation); } [Theory] From 6c4cd2bbc6d3f86d499f3f4b42020251799ee59a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:31:33 +0200 Subject: [PATCH 028/138] docs(launcher): close Campaign LA2 integration --- CLAUDE.md | 7 ++++--- docs/plans/2026-04-11-roadmap.md | 8 +++++--- docs/plans/2026-08-14-launcher-campaign.md | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c9973e8..ec636c8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,10 +245,11 @@ NO 3D preview (chargen-only machinery); UI Studio no longer exists (deleted at Campaign V — ignore stale memory/docs claims otherwise); App `Program.cs` has no subcommand dispatch (the `--session-config` flag is additive). -LA0, LA1, LA3, and LA7a are review-closed. The launcher composer is now +LA0, LA1, LA2, LA3, and LA7a are review-closed. The launcher composer is now compiled into both host test suites, and Launcher.Core runs in the portable -Windows/Ubuntu CI closure. LA2's probe/idle review fixes are the active edge; -LA4/LA5/LA7b follow in parallel after that integration. +Windows/Ubuntu CI closure. Probe mode and the canonical idle policy are merged; +LA4 Avalonia, LA5 plugin hosting, and LA7b selection state/flow are the active +parallel wave. **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 994b83cc..f05cf8ac 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -103,11 +103,13 @@ a future campaign). Spec: [`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); plan + ledger: [`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). -LA0, LA1, LA3, and LA7a are review-closed: the portable path boundary, +LA0, LA1, LA2, LA3, and LA7a are review-closed: the portable path boundary, failure-isolated launch/status contract, BCL-only launcher core, shared composer-to-both-host-loader anti-drift gate, and character wire messages are -landed. LA2 probe/idle review fixes are the active recovery edge before the -Avalonia/plugin/selection parallel wave. +landed. Probe mode now terminates before selection/world entry only after a +roster has been reported, and the idle policy preserves the canonical host +lifecycle. LA4 Avalonia, LA5 plugin hosting, and LA7b selection state/flow are +the active parallel wave. **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index bf5fbf20..00913fb2 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -488,7 +488,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. |---|---|---|---|---| | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. | -| LA2 | **DONE 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Release builds green on Windows and Ubuntu/WSL; Runtime 1,632/1,632 and Headless 149/149 on both. Repeated live ACE probe remains the LA11 user gate. | +| LA2 | **DONE + MERGED 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0`, merge `e01b2cd1` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Branch gates: Runtime 1,632/1,632 and Headless 149/149 on both Windows and Ubuntu/WSL. Integrated gates: Release solution build green; Windows Runtime 1,636/1,636, Headless 151/151, App 5,039+3 skip, Launcher.Core 114/114; WSL Runtime 1,636/1,636, Headless 151/151, Launcher.Core 114/114. Repeated live ACE probe remains the LA11 user gate. | | LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. | | LA4 | — | | | | | LA5 | — | | | | From 95f4be94db100f5602a56c0a80d12ec3d113221b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:12:59 +0200 Subject: [PATCH 029/138] feat(plugins): complete Campaign LA5 cross-host hosting --- AcDream.slnx | 1 + docs/architecture/acdream-architecture.md | 18 +- src/AcDream.App/Plugins/AppPluginHost.cs | 1 + .../Plugins/GraphicalPluginSession.cs | 78 ++++ src/AcDream.App/Program.cs | 80 +--- src/AcDream.App/Rendering/GameWindow.cs | 1 + src/AcDream.Core/Plugins/PluginLoader.cs | 20 +- src/AcDream.Core/Plugins/PluginSession.cs | 361 ++++++++++++++++++ .../Hosting/HeadlessProcessHost.cs | 8 +- .../Hosting/HeadlessSessionHost.cs | 28 +- .../Platform/HeadlessPathSet.cs | 3 + .../Plugins/HeadlessPluginHost.cs | 150 ++++++++ .../Plugins/HeadlessPluginLogger.cs | 43 +++ .../Plugins/HeadlessPluginSession.cs | 109 ++++++ .../IPluginHost.cs | 8 + .../IUiRegistry.cs | 24 +- .../Session/SessionStatusWriter.cs | 35 +- .../AcDream.App.Tests.csproj | 9 + .../Plugins/GraphicalPluginSessionTests.cs | 202 ++++++++++ .../Plugins/PluginLoaderTests.cs | 3 + .../Plugins/PluginSessionTests.cs | 201 ++++++++++ .../AcDream.Headless.Tests.csproj | 9 + .../HeadlessPluginSessionTests.cs | 235 ++++++++++++ ...am.Plugin.Tests.Fixtures.HostPlugin.csproj | 19 + .../HostPlugin.cs | 46 +++ .../Session/SessionStatusWriterTests.cs | 37 +- 26 files changed, 1630 insertions(+), 99 deletions(-) create mode 100644 src/AcDream.App/Plugins/GraphicalPluginSession.cs create mode 100644 src/AcDream.Core/Plugins/PluginSession.cs create mode 100644 src/AcDream.Headless/Plugins/HeadlessPluginHost.cs create mode 100644 src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs create mode 100644 src/AcDream.Headless/Plugins/HeadlessPluginSession.cs create mode 100644 tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs create mode 100644 tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj create mode 100644 tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs diff --git a/AcDream.slnx b/AcDream.slnx index 20f17f98..2dc2d0fd 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -28,6 +28,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4696577b..855aa04d 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -120,7 +120,16 @@ handlers and controllers translate those intents to `WorldSession`; panels never inspect or construct wire messages. Plugins register retained gameplay markup through the BCL-only `AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or -presentation assemblies. Core `SelectionState` is the sole selected-object owner for world, +presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge: +the graphical host supplies its retained registry, while no-window hosts +return `false` and the BCL-only `NoOpUiRegistry`, which retains no plugin +binding. Both hosts use Core's session-scoped discovery/lifetime orchestrator +and the same config allow-list semantics (absent loads all; explicit empty +loads none). The headless adapter projects entity snapshots on demand from the +canonical Runtime view, subscribes to Runtime's ordered events, and borrows the +exact Runtime selection owner; it does not mirror gameplay state. + +Core `SelectionState` is the sole selected-object owner for world, radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins; `IPluginHost.Selection` exposes that same state and retail-style old/new callback. Temporary pointer modes are separate App orchestration in `InteractionState` and @@ -174,6 +183,9 @@ parallel window-lifecycle map. ``` src/ AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic + Plugins/ + PluginSession.cs -> shared per-host allow-list, failure isolation, + status outcome, and collectible ALC lifetime Physics/ PhysicsBody.cs -> body state / integration foundation (done) CollisionPrimitives.cs -> retail primitive helpers (partial, active) @@ -288,6 +300,8 @@ src/ Configuration/ -> strict versioned process/session config Credentials/ -> redacted env/stdin/owner-only-file providers Hosting/ -> one GameRuntime/session/lease/policy lifetime + Plugins/ -> no-window IPluginHost borrowing Runtime/Core; + BCL no-op UI and per-session plugin lifetime Policies/ -> typed Runtime-view/command consumers -> references Runtime only; no presentation/backend package -> Slice K complete: portable single/multi-session production host, @@ -298,6 +312,7 @@ src/ AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces IAcDreamPlugin.cs -> done IPluginHost.cs -> done + IUiRegistry.cs -> capability-aware retained/no-op UI contract IGameState.cs -> done IEvents.cs -> done ISelectionService.cs -> done @@ -352,6 +367,7 @@ src/ PlayerMovementController.cs -> active movement driver Plugins/ AppPluginHost.cs -> done + GraphicalPluginSession.cs -> thin shared-session/root/status adapter ``` The 4B2 production SetPosition routes and shared local-controller body remain diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index bfabab86..dc81ec44 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost Ui = ui; } + public bool HasUi => true; public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } diff --git a/src/AcDream.App/Plugins/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs new file mode 100644 index 00000000..a41ceaab --- /dev/null +++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs @@ -0,0 +1,78 @@ +using AcDream.Core.Plugins; +using AcDream.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Session; + +namespace AcDream.App.Plugins; + +/// +/// Graphical-host composition for one plugin set. The shared +/// owns discovery and collectible lifetimes; this +/// adapter supplies the graphical roots and translates outcomes into the +/// launcher status stream. +/// +internal sealed class GraphicalPluginSession : IDisposable +{ + private readonly PluginSession _plugins; + + private GraphicalPluginSession(PluginSession plugins) + { + _plugins = plugins; + } + + internal int LoadedCount => _plugins.LoadedCount; + + internal IReadOnlyList CaptureLoadContextWeakReferences() => + _plugins.CaptureLoadContextWeakReferences(); + + internal static GraphicalPluginSession Start( + ApplicationPathSet paths, + IReadOnlyList? allowList, + string sessionId, + IPluginHost host, + SessionStatusWriter statusWriter) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(statusWriter); + + var plugins = new PluginSession( + host, + status => Report(statusWriter, sessionId, status)); + try + { + plugins.Start( + [ + Path.Combine(AppContext.BaseDirectory, "plugins"), + paths.PluginsDirectory, + ], + allowList); + return new GraphicalPluginSession(plugins); + } + catch + { + plugins.Dispose(); + throw; + } + } + + public void Dispose() => _plugins.Dispose(); + + private static void Report( + SessionStatusWriter writer, + string sessionId, + PluginSessionStatus status) + { + if (status.Kind == PluginSessionStatusKind.Loaded) + { + writer.PluginLoaded(sessionId, status.Plugin); + return; + } + + writer.PluginFailed( + sessionId, + status.Plugin, + status.Error ?? "plugin failed"); + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 52e647be..7b2c6265 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -4,7 +4,6 @@ using AcDream.App.Credentials; using AcDream.App.Plugins; using AcDream.App.Platform; using AcDream.App.Rendering; -using AcDream.Core.Plugins; using AcDream.Platform; using Serilog; @@ -162,76 +161,15 @@ var host = new AppPluginHost( worldEvents, window.Selection, uiRegistry); - -var loaded = new List(); -var loadedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase); -StringComparer pathComparer = - graphicalPlatform.OperatingSystem - == GraphicalHostOperatingSystem.Windows - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; -string[] pluginRoots = -[ - .. new[] - { - Path.Combine(AppContext.BaseDirectory, "plugins"), - applicationPaths.PluginsDirectory, - }.Distinct(pathComparer), -]; - -foreach (string pluginsDir in pluginRoots) -{ - Log.Information("scanning plugins in {PluginsDir}", pluginsDir); - foreach (var result in PluginDiscovery.Scan(pluginsDir)) - { - if (!result.Success) - { - Log.Warning( - "plugin discovery failed for {Dir}: {Error}", - result.PluginDirectory, - result.Error); - continue; - } - - if (loadedPluginIds.Contains(result.Manifest!.Id)) - { - Log.Warning( - "skipping duplicate plugin id {Id} from {Dir}", - result.Manifest.Id, - result.PluginDirectory); - continue; - } - - var loadResult = PluginLoader.Load( - result.PluginDirectory, - result.Manifest, - host); - if (!loadResult.Success) - { - Log.Warning( - "plugin load failed for {Id}: {Error}", - result.Manifest.Id, - loadResult.Error); - continue; - } - - loadedPluginIds.Add(result.Manifest.Id); - loaded.Add(loadResult); - Log.Information( - "loaded plugin {Id} ({DisplayName})", - result.Manifest.Id, - result.Manifest.DisplayName); - } -} +using var pluginSession = GraphicalPluginSession.Start( + applicationPaths, + runtimeOptions.Plugins, + runtimeOptions.SessionId ?? "app", + host, + window.StatusWriter); try { - foreach (var plugin in loaded) - { - try { plugin.Plugin!.Enable(); } - catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } - } - try { window.Run(); @@ -244,11 +182,7 @@ try } finally { - foreach (var plugin in loaded) - { - try { plugin.Plugin!.Disable(); } - catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); } - } + pluginSession.Dispose(); Log.CloseAndFlush(); } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f3be7ef2..a3ec07eb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -362,6 +362,7 @@ public sealed class GameWindow : private RuntimeActionState _runtimeActions => _runtime.ActionOwner; public AcDream.Core.Selection.SelectionState Selection => _runtimeActions.Selection; + internal SessionStatusWriter StatusWriter => _statusWriter; public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat; public AcDream.Core.Chat.TurbineChatState TurbineChat => _runtimeCommunication.TurbineChat; diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index ba2ba07d..85581642 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -14,6 +14,10 @@ public static class PluginLoader /// public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(host); + var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll); if (!File.Exists(dllPath)) return new LoadedPlugin( @@ -22,9 +26,11 @@ public static class PluginLoader LoadContext: null, Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath)); + PluginAssemblyLoadContext? alc = null; + IAcDreamPlugin? instance = null; try { - var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); + alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); var asm = alc.LoadFromAssemblyPath(dllPath); IEnumerable types; @@ -41,19 +47,29 @@ public static class PluginLoader .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); if (pluginType is null) + { + alc.Unload(); return new LoadedPlugin( manifest, Plugin: null, LoadContext: null, Error: new InvalidOperationException( $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); + } - var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; + instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; instance.Initialize(host); return new LoadedPlugin(manifest, instance, alc, Error: null); } catch (Exception ex) { + // Initialize may have attached host callbacks before it failed. + // Give that partial instance the same best-effort cleanup chance + // as an Enable failure before releasing the collectible context. + try { instance?.Disable(); } + catch { } + try { alc?.Unload(); } + catch { } return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex); } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs new file mode 100644 index 00000000..5c939fd7 --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -0,0 +1,361 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public enum PluginSessionStatusKind +{ + Loaded, + Failed, +} + +/// +/// Final startup outcome for one configured plugin id. Hosts translate these +/// outcomes into their own diagnostics and the Campaign LA status stream. +/// +public readonly record struct PluginSessionStatus( + string Plugin, + PluginSessionStatusKind Kind, + string? Error = null); + +/// +/// One host/session-scoped plugin lifetime. Discovery, allow-listing, +/// initialize/enable, failure isolation, reverse-order disable, and collectible +/// load-context release are shared by graphical and no-window hosts so their +/// configured plugin-set semantics cannot drift. +/// +public sealed class PluginSession : IDisposable +{ + private readonly IPluginHost _host; + private readonly Action? _report; + private readonly List _loaded = []; + private bool _started; + private bool _disposed; + + public PluginSession( + IPluginHost host, + Action? report = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _report = report; + } + + public int LoadedCount => _loaded.Count; + + public IReadOnlyList LoadedPluginIds => + _loaded.Select(static plugin => plugin.Manifest.Id).ToArray(); + + /// + /// Discovers and starts the configured set exactly once. A + /// allow-list loads every discovered id; an explicit + /// empty list loads none. Matching and duplicate-id handling are + /// ordinal-ignore-case on every operating system because plugin ids are + /// logical identifiers, not paths. + /// + public void Start( + IEnumerable pluginRoots, + IReadOnlyList? allowList) + { + ArgumentNullException.ThrowIfNull(pluginRoots); + ObjectDisposedException.ThrowIf(_disposed, this); + if (_started) + throw new InvalidOperationException("The plugin session has already started."); + _started = true; + + string[] roots = DistinctRoots(pluginRoots); + string[]? requested = allowList is null + ? null + : allowList + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (requested is { Length: 0 }) + return; + + var candidates = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + var errors = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + var discoveredOrder = new List(); + HashSet? requestedSet = requested is null + ? null + : new HashSet(requested, StringComparer.OrdinalIgnoreCase); + + foreach (string root in roots) + { + IReadOnlyList results; + try + { + results = PluginDiscovery.Scan(root); + } + catch (Exception error) when (IsDiscoveryFailure(error)) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin discovery failed for root '{root}'", + error); + continue; + } + + foreach (PluginDiscoveryResult result in results) + { + if (!result.Success) + { + string directoryId = Path.GetFileName( + Path.TrimEndingDirectorySeparator(result.PluginDirectory)); + if (string.IsNullOrWhiteSpace(directoryId) + || (requestedSet is not null + && !requestedSet.Contains(directoryId))) + { + continue; + } + + AddOrdered(discoveredOrder, directoryId); + AddError( + errors, + directoryId, + result.Error ?? new InvalidOperationException( + "plugin discovery failed")); + continue; + } + + string id = result.Manifest!.Id; + if (requestedSet is not null && !requestedSet.Contains(id)) + continue; + AddOrdered(discoveredOrder, id); + if (!candidates.TryGetValue(id, out List? list)) + { + list = []; + candidates.Add(id, list); + } + list.Add(result); + } + } + + IEnumerable loadOrder = requested is null + ? discoveredOrder + : requested; + foreach (string id in loadOrder) + LoadOne(id, candidates, errors); + } + + /// + /// Test/diagnostic observation of the exact collectible contexts currently + /// owned by this session. The returned weak references do not delay unload. + /// + public IReadOnlyList CaptureLoadContextWeakReferences() => + _loaded + .Select(static plugin => new WeakReference(plugin.LoadContext!)) + .ToArray(); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + for (int index = _loaded.Count - 1; index >= 0; index--) + { + LoadedPlugin loaded = _loaded[index]; + try + { + loaded.Plugin!.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin disable failed: {loaded.Manifest.Id}", + error); + } + + try + { + loaded.LoadContext!.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload failed: {loaded.Manifest.Id}", + error); + } + } + + // Drop both plugin instances and AssemblyLoadContext references. The + // CLR completes collectible unload after no plugin-owned object remains + // reachable and a normal GC cycle observes the contexts. + _loaded.Clear(); + } + + private void LoadOne( + string id, + IReadOnlyDictionary> candidates, + Dictionary> errors) + { + if (candidates.TryGetValue(id, out List? available)) + { + foreach (PluginDiscoveryResult candidate in available) + { + LoadedPlugin loaded = PluginLoader.Load( + candidate.PluginDirectory, + candidate.Manifest!, + _host); + if (!loaded.Success) + { + AddError( + errors, + id, + loaded.Error ?? new InvalidOperationException( + "plugin load failed")); + continue; + } + + try + { + loaded.Plugin!.Enable(); + _loaded.Add(loaded); + SafeLog( + static (log, message, _) => log.Info(message), + $"plugin loaded: {loaded.Manifest.Id} " + + $"({loaded.Manifest.DisplayName})", + null); + Report(new PluginSessionStatus( + loaded.Manifest.Id, + PluginSessionStatusKind.Loaded)); + return; + } + catch (Exception error) + { + AddError(errors, id, error); + ReleaseFailedEnable(loaded); + } + } + } + + if (!errors.TryGetValue(id, out List? failures) + || failures.Count == 0) + { + failures = + [ + new FileNotFoundException( + $"plugin '{id}' was not found in the configured plugin roots."), + ]; + } + + string errorText = string.Join( + " | ", + failures.Select(Describe)); + Report(new PluginSessionStatus( + id, + PluginSessionStatusKind.Failed, + errorText)); + SafeLog( + static (log, message, _) => log.Warn(message), + $"plugin failed: {id}: {errorText}", + null); + } + + private void ReleaseFailedEnable(LoadedPlugin loaded) + { + try + { + loaded.Plugin!.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}", + error); + } + + try + { + loaded.LoadContext!.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload after enable failure failed: {loaded.Manifest.Id}", + error); + } + } + + private void Report(PluginSessionStatus status) + { + if (_report is null) + return; + try + { + _report(status); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin status observer failed for {status.Plugin}", + error); + } + } + + private void SafeLog( + Action write, + string message, + Exception? error) + { + try { write(_host.Log, message, error); } + catch { } + } + + private static string[] DistinctRoots(IEnumerable roots) + { + StringComparer comparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + return roots + .Where(static root => !string.IsNullOrWhiteSpace(root)) + .Select(Path.GetFullPath) + .Distinct(comparer) + .ToArray(); + } + + private static void AddOrdered(List ordered, string id) + { + if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase)) + ordered.Add(id); + } + + private static void AddError( + Dictionary> errors, + string id, + Exception error) + { + if (!errors.TryGetValue(id, out List? list)) + { + list = []; + errors.Add(id, list); + } + list.Add(error); + } + + private static string Describe(Exception error) + { + Exception root = error.GetBaseException(); + return string.IsNullOrWhiteSpace(root.Message) + ? root.GetType().Name + : root.Message; + } + + private static bool IsDiscoveryFailure(Exception error) => + error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Security.SecurityException; +} diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 5d38edb0..c620c52a 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable paths.ConfigDirectory); var sessions = new List( configuration.Sessions.Count); + string[] pluginRoots = + [ + Path.Combine(AppContext.BaseDirectory, "plugins"), + paths.PluginsDirectory, + ]; HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; // FA6: constructed unconditionally — cheap, and every non-gate @@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable sessionOperations, timeProvider, contentLease: contentLease, - gateCoordinator: gateCoordinator)); + gateCoordinator: gateCoordinator, + pluginRoots: pluginRoots)); } catch { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 0f6340a1..eb0ce2a5 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -1,6 +1,7 @@ using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; +using AcDream.Headless.Plugins; using AcDream.Headless.Policies; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -155,6 +156,7 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly IDisposable _hostLease; private readonly IHeadlessBotPolicy _policy; private readonly IDisposable _policySubscription; + private readonly HeadlessPluginSession _pluginSession; private readonly LiveSessionHost _liveSession; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? @@ -238,7 +240,8 @@ internal sealed class HeadlessSessionHost : IDisposable contentLease = null, IHeadlessBotPolicy? policyOverride = null, IRuntimePlacementProjectionSink? placementSinkOverride = null, - FellowshipAllegianceGateCoordinator? gateCoordinator = null) + FellowshipAllegianceGateCoordinator? gateCoordinator = null, + IEnumerable? pluginRoots = null) { _descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); @@ -263,6 +266,7 @@ internal sealed class HeadlessSessionHost : IDisposable IDisposable? hostLease = null; IHeadlessBotPolicy? policy = null; IDisposable? policySubscription = null; + HeadlessPluginSession? pluginSession = null; try { var gameplay = new HeadlessGameplayOperations(); @@ -297,6 +301,13 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); + pluginSession = HeadlessPluginSession.Start( + runtime, + diagnostics, + statusWriter, + descriptor.Id, + pluginRoots ?? [], + descriptor.Plugins); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( @@ -402,9 +413,11 @@ internal sealed class HeadlessSessionHost : IDisposable _hostLease = hostLease; _policy = policy; _policySubscription = policySubscription; + _pluginSession = pluginSession; } catch { + pluginSession?.Dispose(); policySubscription?.Dispose(); policy?.Dispose(); hostLease?.Dispose(); @@ -427,6 +440,7 @@ internal sealed class HeadlessSessionHost : IDisposable /// production code uses to reach the same state. /// internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; + internal HeadlessPluginSession Plugins => _pluginSession; internal string SessionId => _descriptor.Id; internal string ActiveCharacterName { get; private set; } = string.Empty; @@ -638,22 +652,26 @@ internal sealed class HeadlessSessionHost : IDisposable _disposeStage++; break; case 4: - _hostLease.Dispose(); + _pluginSession.Dispose(); _disposeStage++; break; case 5: - _credential.Dispose(); + _hostLease.Dispose(); _disposeStage++; break; case 6: - Runtime.Dispose(); + _credential.Dispose(); _disposeStage++; break; case 7: - _contentLease?.Dispose(); + Runtime.Dispose(); _disposeStage++; break; case 8: + _contentLease?.Dispose(); + _disposeStage++; + break; + case 9: _diagnostics.Message( _descriptor.Id, "disposed", diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs index bdbe1866..e8e110e2 100644 --- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet( string DataDirectory, string CacheDirectory) { + internal string PluginsDirectory => + Path.Combine(DataDirectory, "plugins"); + internal static HeadlessPathSet Resolve( HeadlessPathOverrides overrides, IHeadlessPlatformEnvironment? platform = null) diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs new file mode 100644 index 00000000..f68c4bc5 --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -0,0 +1,150 @@ +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; + +namespace AcDream.Headless.Plugins; + +/// +/// No-window plugin surface over one exact . State is +/// projected on demand from Runtime's canonical entity view, events come from +/// Runtime's ordered event source, and selection is the exact J5 action owner; +/// this adapter owns no gameplay mirror. +/// +internal sealed class HeadlessPluginHost + : IPluginHost, + IGameState, + IEvents, + IRuntimeEventObserver, + IDisposable +{ + private readonly GameRuntime _runtime; + private readonly IDisposable _eventSubscription; + private readonly object _eventGate = new(); + private Action? _entitySpawned; + private bool _disposed; + + internal HeadlessPluginHost( + GameRuntime runtime, + IPluginLogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + Log = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventSubscription = runtime.Subscribe(this); + } + + public bool HasUi => false; + public IPluginLogger Log { get; } + public IGameState State => this; + public IEvents Events => this; + public ISelectionService Selection => _runtime.ActionOwner.Selection; + public IUiRegistry Ui => NoOpUiRegistry.Instance; + + /// + /// Immutable point-in-time values produced directly from Runtime on each + /// read. The caller owns the returned snapshot list; this host retains no + /// entity collection and therefore cannot become a second gameplay owner. + /// + public IReadOnlyList Entities + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + var visitor = new SnapshotVisitor(_runtime); + _runtime.Entities.Visit(visitor); + return visitor.Snapshots; + } + } + + public event Action EntitySpawned + { + add + { + ArgumentNullException.ThrowIfNull(value); + ObjectDisposedException.ThrowIf(_disposed, this); + lock (_eventGate) + _entitySpawned += value; + + // Match the graphical WorldEvents contract: a late subscriber + // immediately observes the canonical world that exists now. + foreach (WorldEntitySnapshot snapshot in Entities) + Invoke(value, snapshot); + } + remove + { + if (value is null) + return; + lock (_eventGate) + _entitySpawned -= value; + } + } + + public void Dispose() + { + if (_disposed) + return; + _eventSubscription.Dispose(); + _disposed = true; + lock (_eventGate) + _entitySpawned = null; + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + if (_disposed || delta.Change != RuntimeEntityChange.Registered) + return; + Action? handlers; + lock (_eventGate) + handlers = _entitySpawned; + if (handlers is null) + return; + + WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity); + foreach (Action handler + in handlers.GetInvocationList().Cast>()) + { + Invoke(handler, snapshot); + } + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) { } + public void OnCommand(in RuntimeCommandDelta delta) { } + public void OnInventory(in RuntimeInventoryDelta delta) { } + public void OnChat(in RuntimeChatDelta delta) { } + public void OnMovement(in RuntimeMovementDelta delta) { } + public void OnPortal(in RuntimePortalDelta delta) { } + public void OnCombat(in RuntimeCombatDelta delta) { } + + private static WorldEntitySnapshot Convert( + GameRuntime runtime, + in RuntimeEntitySnapshot entity) + { + uint sourceId = runtime.EntityObjects.Entities.TryGetActive( + entity.Identity.ServerGuid, + out AcDream.Runtime.Entities.RuntimeEntityRecord record) + ? record.Snapshot.SetupTableId ?? 0u + : 0u; + return new WorldEntitySnapshot( + entity.Identity.LocalEntityId, + sourceId, + entity.Position?.Frame.Origin ?? default, + entity.Position?.Frame.Orientation + ?? System.Numerics.Quaternion.Identity); + } + + private static void Invoke( + Action handler, + WorldEntitySnapshot snapshot) + { + try { handler(snapshot); } + catch { } + } + + private sealed class SnapshotVisitor(GameRuntime runtime) + : IRuntimeEntityVisitor + { + internal List Snapshots { get; } = + new(runtime.Entities.Count); + + public void Visit(in RuntimeEntitySnapshot entity) => + Snapshots.Add(Convert(runtime, entity)); + } +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs new file mode 100644 index 00000000..8b8494c2 --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs @@ -0,0 +1,43 @@ +using AcDream.Headless.Diagnostics; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Headless.Plugins; + +internal sealed class HeadlessPluginLogger : IPluginLogger +{ + private readonly HeadlessDiagnosticWriter _diagnostics; + private readonly string _sessionId; + private readonly Func _generation; + + internal HeadlessPluginLogger( + HeadlessDiagnosticWriter diagnostics, + string sessionId, + Func generation) + { + _diagnostics = diagnostics + ?? throw new ArgumentNullException(nameof(diagnostics)); + _sessionId = sessionId + ?? throw new ArgumentNullException(nameof(sessionId)); + _generation = generation + ?? throw new ArgumentNullException(nameof(generation)); + } + + public void Info(string message) => Write("info", message); + public void Warn(string message) => Write("warn", message); + + public void Error(string message, Exception? exception = null) + { + if (exception is not null) + { + _diagnostics.Failure(_sessionId, "plugin", exception); + return; + } + Write("error", message); + } + + private void Write(string level, string message) => + _diagnostics.Message( + _sessionId, + $"plugin-{level}:{message}", + _generation()); +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs new file mode 100644 index 00000000..cff1226a --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -0,0 +1,109 @@ +using AcDream.Core.Plugins; +using AcDream.Headless.Diagnostics; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Plugins; + +/// +/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload +/// ahead of canonical Runtime disposal. +/// +internal sealed class HeadlessPluginSession : IDisposable +{ + private readonly HeadlessPluginHost _host; + private readonly PluginSession _plugins; + private int _disposeStage; + private bool _disposed; + + private HeadlessPluginSession( + HeadlessPluginHost host, + PluginSession plugins) + { + _host = host; + _plugins = plugins; + } + + internal int LoadedCount => _plugins.LoadedCount; + internal IPluginHost Host => _host; + + internal IReadOnlyList CaptureLoadContextWeakReferences() => + _plugins.CaptureLoadContextWeakReferences(); + + internal static HeadlessPluginSession Start( + GameRuntime runtime, + HeadlessDiagnosticWriter diagnostics, + SessionStatusWriter statusWriter, + string sessionId, + IEnumerable roots, + IReadOnlyList? allowList) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(diagnostics); + ArgumentNullException.ThrowIfNull(statusWriter); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(roots); + + var host = new HeadlessPluginHost( + runtime, + new HeadlessPluginLogger( + diagnostics, + sessionId, + () => runtime.Generation.Value)); + var plugins = new PluginSession( + host, + status => Report(statusWriter, sessionId, status)); + try + { + plugins.Start(roots, allowList); + return new HeadlessPluginSession(host, plugins); + } + catch + { + plugins.Dispose(); + host.Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + return; + while (!_disposed) + { + switch (_disposeStage) + { + case 0: + _plugins.Dispose(); + _disposeStage++; + break; + case 1: + _host.Dispose(); + _disposeStage++; + _disposed = true; + break; + default: + throw new InvalidOperationException( + "Unknown headless plugin teardown stage."); + } + } + } + + private static void Report( + SessionStatusWriter writer, + string sessionId, + PluginSessionStatus status) + { + if (status.Kind == PluginSessionStatusKind.Loaded) + { + writer.PluginLoaded(sessionId, status.Plugin); + return; + } + writer.PluginFailed( + sessionId, + status.Plugin, + status.Error ?? "plugin failed"); + } +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index f3690107..4ece2480 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -7,6 +7,14 @@ namespace AcDream.Plugin.Abstractions; /// public interface IPluginHost { + /// + /// when registrations can be + /// projected by this host. No-window hosts return + /// and expose so a plugin may keep + /// one code path while deliberately omitting presentation work. + /// + bool HasUi { get; } + IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs index 1b724f1a..0550f170 100644 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -3,8 +3,10 @@ namespace AcDream.Plugin.Abstractions; /// /// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + /// a binding object exposing the data properties the markup binds to, and -/// registers it from Enable(). Calls made before the GL window opens are -/// buffered and drained once the UI host exists. +/// registers it from Enable(). Graphical hosts buffer registrations until +/// their retained UI exists. A host whose is +/// exposes and +/// intentionally discards registrations. /// public interface IUiRegistry { @@ -12,3 +14,21 @@ public interface IUiRegistry /// Object whose properties the markup's {Bindings} resolve against. void AddMarkupPanel(string markupPath, object binding); } + +/// +/// BCL-only UI sink for no-window plugin hosts. It intentionally retains +/// neither markup paths nor binding objects, so a UI registration cannot keep +/// a plugin assembly alive after its collectible load context is unloaded. +/// +public sealed class NoOpUiRegistry : IUiRegistry +{ + public static NoOpUiRegistry Instance { get; } = new(); + + private NoOpUiRegistry() + { + } + + public void AddMarkupPanel(string markupPath, object binding) + { + } +} diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index b98e4761..2b3461fa 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -13,9 +13,9 @@ namespace AcDream.Runtime.Session; /// is a single shared-stdout JSONL diagnostics stream with no per-session /// file; this class writes one file per session, meant to be read by an /// external process (the launcher) rather than scraped from console output. -/// Event shapes are versioned ("v":1) so a future event kind -/// (pluginLoaded/pluginFailed, LA5) can be added without -/// breaking an existing reader. +/// Event shapes are versioned ("v":1); LA5's +/// pluginLoaded/pluginFailed additions use that same envelope +/// without breaking an existing reader. /// /// /// @@ -29,9 +29,11 @@ namespace AcDream.Runtime.Session; /// /// /// -/// Never write credential material into this stream. Every -/// event method below takes only identifiers, names, and counts — there is no -/// parameter shape that could carry a password, by construction. +/// Never write credential material into this stream. LA5's +/// diagnostic is caller-supplied text, so hosts may +/// pass only the plugin lifecycle failure and must never append session +/// credentials or other secrets. Neither plugin host exposes credentials +/// through IPluginHost. /// /// /// @@ -205,6 +207,27 @@ public sealed class SessionStatusWriter characterName, }); + public void PluginLoaded(string sessionId, string plugin) => + Write(new + { + v = VocabularyVersion, + e = "pluginLoaded", + t = Now(), + sessionId, + plugin, + }); + + public void PluginFailed(string sessionId, string plugin, string error) => + Write(new + { + v = VocabularyVersion, + e = "pluginFailed", + t = Now(), + sessionId, + plugin, + error, + }); + public void Disconnected(string sessionId, string reason) { if (!IsEnabled) diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 6760881b..8f82d7ba 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -25,6 +25,15 @@ + + + + false + true + + + diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs new file mode 100644 index 00000000..b77e2cb6 --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using System.Runtime.CompilerServices; +using AcDream.App.Plugins; +using AcDream.Core.Plugins; +using AcDream.Core.Selection; +using AcDream.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Plugins; + +public sealed class GraphicalPluginSessionTests +{ + private const string FixtureId = "acdream.test.host-fixture"; + + [Fact] + public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + InstallFixture(paths.PluginsDirectory, FixtureId); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var logger = new CapturingLogger(); + var state = new WorldGameState(); + var events = new WorldEvents(); + var selection = new SelectionState(); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost(logger, state, events, selection, ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + paths, + [FixtureId.ToUpperInvariant(), "acdream.test.missing"], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + + Assert.Equal(1, plugins.LoadedCount); + Assert.True(host.HasUi); + AssertPanelWasRegisteredAndReleaseBinding(ui); + Assert.Contains( + logger.Messages, + message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal)); + + JsonElement[] statuses = ReadStatuses(statusPath); + Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); + Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); + Assert.Equal( + "acdream.test.missing", + statuses[1].GetProperty("plugin").GetString()); + Assert.Contains( + "not found", + statuses[1].GetProperty("error").GetString(), + StringComparison.OrdinalIgnoreCase); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + plugins.Dispose(); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void ExplicitEmptyConfiguredSetLoadsNone() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + InstallFixture(paths.PluginsDirectory, FixtureId); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost( + new CapturingLogger(), + new WorldGameState(), + new WorldEvents(), + new SelectionState(), + ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + paths, + [], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Empty(ui.Drain()); + Assert.False(File.Exists(statusPath)); + } + + private static ApplicationPathSet Paths(string root) => new( + Path.Combine(root, "config"), + Path.Combine(root, "data"), + Path.Combine(root, "cache"), + LegacyConfigDirectory: null); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AssertPanelWasRegisteredAndReleaseBinding( + BufferedUiRegistry ui) + { + BufferedUiRegistry.Pending panel = Assert.Single(ui.Drain()); + Assert.EndsWith( + "fixture-panel.xml", + panel.MarkupPath, + StringComparison.Ordinal); + Assert.Equal( + "AcDream.Plugin.Tests.Fixtures.HostPlugin", + panel.Binding.GetType().Assembly.GetName().Name); + } + + private static JsonElement[] ReadStatuses(string path) => + File.ReadAllLines(path) + .Select(static line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + + private static string[] EventNames(IEnumerable events) => + events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); + + private static void InstallFixture(string root, string id) + { + string source = FixtureAssemblyPath(); + Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); + string pluginDirectory = Path.Combine(root, "host-fixture"); + Directory.CreateDirectory(pluginDirectory); + string fileName = Path.GetFileName(source); + File.Copy(source, Path.Combine(pluginDirectory, fileName)); + File.WriteAllText( + Path.Combine(pluginDirectory, "plugin.json"), + JsonSerializer.Serialize(new + { + id, + displayName = "Host fixture", + version = "1.0.0", + entryDll = fileName, + apiVersion = 1, + })); + } + + private static string FixtureAssemblyPath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + string root = FindRepoRoot(AppContext.BaseDirectory); + return Path.Combine( + root, + "tests", + "AcDream.Plugin.Tests.Fixtures.HostPlugin", + "bin", + configuration, + "net10.0", + "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + } + + private static string FindRepoRoot(string start) + { + DirectoryInfo? directory = new(start); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Repository root not found."); + } + + private static void Collect(WeakReference reference) + { + for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + + private sealed class CapturingLogger : IPluginLogger + { + internal List Messages { get; } = []; + + public void Info(string message) => Messages.Add(message); + public void Warn(string message) => Messages.Add(message); + public void Error(string message, Exception? exception = null) => + Messages.Add(exception is null ? message : $"{message}: {exception.Message}"); + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-graphical-plugins-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index a55fb398..3deebb94 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -28,6 +28,7 @@ public class PluginLoaderTests private sealed class StubHost : IPluginHost { + public bool HasUi => true; public IPluginLogger Log { get; } = new StubLogger(); public IGameState State { get; } = new StubState(); public IEvents Events { get; } = new StubEvents(); @@ -84,6 +85,8 @@ public class PluginLoaderTests Assert.True(loaded.Success); Assert.NotNull(loaded.Plugin); Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name); + loaded.Plugin.Disable(); + loaded.LoadContext!.Unload(); } [Fact] diff --git a/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs new file mode 100644 index 00000000..5a3ef89c --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs @@ -0,0 +1,201 @@ +using System.Text.Json; +using AcDream.Core.Plugins; +using AcDream.Core.Selection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Plugins; + +public sealed class PluginSessionTests +{ + [Fact] + public void AbsentAllowListLoadsEveryDiscoveredPlugin() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, "alpha", "acdream.test.alpha"); + InstallFixture(temporary.Path, "beta", "acdream.test.beta"); + var statuses = new List(); + var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start([temporary.Path], allowList: null); + + Assert.Equal(2, plugins.LoadedCount); + Assert.Equal( + ["acdream.test.alpha", "acdream.test.beta"], + plugins.LoadedPluginIds); + Assert.All( + statuses, + status => Assert.Equal(PluginSessionStatusKind.Loaded, status.Kind)); + ReleaseAndCollect(plugins); + } + + [Fact] + public void AllowListIsCaseInsensitiveAndOneFailureDoesNotBlockAnotherPlugin() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, "good", "acdream.test.good"); + InstallBroken(temporary.Path, "broken", "acdream.test.broken"); + var statuses = new List(); + var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start( + [temporary.Path], + [ + "ACDREAM.TEST.BROKEN", + "ACDREAM.TEST.GOOD", + "acdream.test.missing", + ]); + + Assert.Equal(["acdream.test.good"], plugins.LoadedPluginIds); + Assert.Equal( + [ + ("ACDREAM.TEST.BROKEN", PluginSessionStatusKind.Failed), + ("acdream.test.good", PluginSessionStatusKind.Loaded), + ("acdream.test.missing", PluginSessionStatusKind.Failed), + ], + statuses.Select(static status => (status.Plugin, status.Kind))); + Assert.All( + statuses.Where(static status => status.Kind == PluginSessionStatusKind.Failed), + status => Assert.False(string.IsNullOrWhiteSpace(status.Error))); + ReleaseAndCollect(plugins); + } + + [Fact] + public void ExplicitEmptyAllowListLoadsNothing() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, "fixture", "acdream.test.fixture"); + var statuses = new List(); + using var plugins = new PluginSession(new StubHost(), statuses.Add); + + plugins.Start([temporary.Path], []); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Empty(statuses); + } + + private static void ReleaseAndCollect(PluginSession plugins) + { + IReadOnlyList contexts = + plugins.CaptureLoadContextWeakReferences(); + plugins.Dispose(); + for (int attempt = 0; + attempt < 10 && contexts.Any(static context => context.IsAlive); + attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + Assert.All(contexts, static context => Assert.False(context.IsAlive)); + } + + private static void InstallFixture(string root, string folder, string id) + { + string source = FixturePluginPath(); + Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); + string pluginDirectory = Path.Combine(root, folder); + Directory.CreateDirectory(pluginDirectory); + string fileName = Path.GetFileName(source); + File.Copy(source, Path.Combine(pluginDirectory, fileName)); + WriteManifest(pluginDirectory, id, fileName); + } + + private static void InstallBroken(string root, string folder, string id) + { + string pluginDirectory = Path.Combine(root, folder); + Directory.CreateDirectory(pluginDirectory); + WriteManifest(pluginDirectory, id, "missing.dll"); + } + + private static void WriteManifest( + string directory, + string id, + string entryDll) => + File.WriteAllText( + Path.Combine(directory, "plugin.json"), + JsonSerializer.Serialize(new + { + id, + displayName = id, + version = "1.0.0", + entryDll, + apiVersion = 1, + })); + + private static string FixturePluginPath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + string root = FindRepoRoot(AppContext.BaseDirectory); + return Path.Combine( + root, + "tests", + "AcDream.Core.Tests.Fixtures.HelloPlugin", + "bin", + configuration, + "net10.0", + "AcDream.Core.Tests.Fixtures.HelloPlugin.dll"); + } + + private static string FindRepoRoot(string start) + { + DirectoryInfo? directory = new(start); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Repository root not found."); + } + + private sealed class StubHost : IPluginHost + { + public bool HasUi => false; + public IPluginLogger Log { get; } = new StubLogger(); + public IGameState State { get; } = new StubState(); + public IEvents Events { get; } = new StubEvents(); + public ISelectionService Selection { get; } = new SelectionState(); + public IUiRegistry Ui => NoOpUiRegistry.Instance; + } + + private sealed class StubLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? exception = null) { } + } + + private sealed class StubState : IGameState + { + public IReadOnlyList Entities => []; + } + + private sealed class StubEvents : IEvents + { + public event Action EntitySpawned + { + add { } + remove { } + } + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-plugin-session-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj index 9591a359..fa871545 100644 --- a/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj +++ b/tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj @@ -24,6 +24,15 @@ + + + + false + true + + + diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs new file mode 100644 index 00000000..130dc6d3 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -0,0 +1,235 @@ +using System.Text.Json; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Hosting; +using AcDream.Headless.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessPluginSessionTests +{ + private const string FixtureId = "acdream.test.host-fixture"; + private const string BrokenId = "acdream.test.broken"; + + [Fact] + public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, FixtureId); + InstallBrokenPlugin(temporary.Path, BrokenId); + var output = new StringWriter(); + var diagnostics = new HeadlessDiagnosticWriter(output); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath), + credential, + diagnostics, + pluginRoots: [temporary.Path]); + HeadlessPluginSession plugins = session.Plugins; + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + + Assert.Equal(1, plugins.LoadedCount); + Assert.False(plugins.Host.HasUi); + Assert.Same(NoOpUiRegistry.Instance, plugins.Host.Ui); + Assert.Same( + session.Runtime.ActionOwner.Selection, + plugins.Host.Selection); + WorldEntitySnapshot first = Assert.Single(plugins.Host.State.Entities); + Assert.Equal(1_000_000u, first.Id); + Assert.Equal(0x02000001u, first.SourceId); + + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); + Assert.Equal(2, plugins.Host.State.Entities.Count); + + JsonElement[] statuses = ReadStatuses(statusPath); + Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); + Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); + Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString()); + Assert.Contains( + "entry dll not found", + statuses[1].GetProperty("error").GetString()!, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString()); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + session.Dispose(); + Assert.Contains("fixture-disabled:entitiesSeen=2", output.ToString()); + Assert.True(session.Runtime.CaptureOwnership().IsConverged); + Assert.True(credential.IsDisposed); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void ExplicitEmptyConfiguredSetLoadsNone() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, FixtureId); + var output = new StringWriter(); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var credential = new HeadlessCredentialSecret("fixture", "password"); + + using var session = new HeadlessSessionHost( + Descriptor([], statusPath), + credential, + new HeadlessDiagnosticWriter(output), + pluginRoots: [temporary.Path]); + + Assert.Equal(0, session.Plugins.LoadedCount); + Assert.False(File.Exists(statusPath)); + Assert.DoesNotContain("fixture-", output.ToString()); + } + + private static HeadlessSessionDescriptor Descriptor( + List plugins, + string statusPath) => new() + { + Id = "headless-session", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Character = new HeadlessCharacterSelector + { + Name = "Fixture", + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.Environment, + Reference = "FIXTURE_PASSWORD", + }, + Plugins = plugins, + StatusFile = statusPath, + }; + + private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new( + guid, + new CreateObject.ServerPosition( + 0x01010001u, + x, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f), + 0x02000001u, + [], + [], + [], + null, + null, + "Fixture", + null, + null, + null); + + private static JsonElement[] ReadStatuses(string path) => + File.ReadAllLines(path) + .Select(static line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + + private static string[] EventNames(IEnumerable events) => + events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); + + private static void InstallFixture(string root, string id) + { + string source = FixtureAssemblyPath(); + Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); + string pluginDirectory = Path.Combine(root, "host-fixture"); + Directory.CreateDirectory(pluginDirectory); + string fileName = Path.GetFileName(source); + File.Copy(source, Path.Combine(pluginDirectory, fileName)); + WriteManifest(pluginDirectory, id, fileName); + } + + private static void InstallBrokenPlugin(string root, string id) + { + string pluginDirectory = Path.Combine(root, "broken"); + Directory.CreateDirectory(pluginDirectory); + WriteManifest(pluginDirectory, id, "missing.dll"); + } + + private static void WriteManifest( + string directory, + string id, + string entryDll) => + File.WriteAllText( + Path.Combine(directory, "plugin.json"), + JsonSerializer.Serialize(new + { + id, + displayName = "Host fixture", + version = "1.0.0", + entryDll, + apiVersion = 1, + })); + + private static string FixtureAssemblyPath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent!.Name; + string root = FindRepoRoot(AppContext.BaseDirectory); + return Path.Combine( + root, + "tests", + "AcDream.Plugin.Tests.Fixtures.HostPlugin", + "bin", + configuration, + "net10.0", + "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + } + + private static string FindRepoRoot(string start) + { + DirectoryInfo? directory = new(start); + while (directory is not null + && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + directory = directory.Parent; + } + return directory?.FullName + ?? throw new InvalidOperationException("Repository root not found."); + } + + private static void Collect(WeakReference reference) + { + for (int attempt = 0; attempt < 10 && reference.IsAlive; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + internal TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-headless-plugins-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + internal string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj new file mode 100644 index 00000000..7b200e49 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + latest + false + true + + + + + false + runtime + + + diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs new file mode 100644 index 00000000..9a1a9143 --- /dev/null +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs @@ -0,0 +1,46 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; + +/// +/// Cross-host LA5 fixture. It deliberately takes the same path on graphical +/// and no-window hosts: observe the capability, register UI, and subscribe to +/// gameplay events. A headless registry must make the UI call harmless without +/// retaining this instance in the default load context. +/// +public sealed class HostPlugin : IAcDreamPlugin +{ + private IPluginHost? _host; + private int _entitiesSeen; + + public void Initialize(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); + } + + public void Enable() + { + IPluginHost host = _host + ?? throw new InvalidOperationException("The fixture was not initialized."); + host.Ui.AddMarkupPanel( + Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), + this); + host.Events.EntitySpawned += OnEntitySpawned; + host.Log.Info( + $"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}"); + } + + public void Disable() + { + IPluginHost? host = _host; + if (host is null) + return; + host.Events.EntitySpawned -= OnEntitySpawned; + host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); + _host = null; + } + + private void OnEntitySpawned(WorldEntitySnapshot snapshot) => + _entitiesSeen++; +} diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index 899c0e0f..f9208079 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -30,11 +30,13 @@ public sealed class SessionStatusWriterTests new LiveSessionRosterEntry(0x50000002u, "Grey", 10u), ])); writer.EnteredWorld("s1", 0x50000001u, "Ready"); + writer.PluginLoaded("s1", "acdream.good"); + writer.PluginFailed("s1", "acdream.bad", "enable failed"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); - Assert.Equal(6, lines.Length); + Assert.Equal(8, lines.Length); JsonElement started = Parse(lines[0]); Assert.Equal(1, started.GetProperty("v").GetInt32()); @@ -62,11 +64,20 @@ public sealed class SessionStatusWriterTests Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32()); Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString()); - JsonElement disconnected = Parse(lines[4]); + JsonElement pluginLoaded = Parse(lines[4]); + Assert.Equal("pluginLoaded", pluginLoaded.GetProperty("e").GetString()); + Assert.Equal("acdream.good", pluginLoaded.GetProperty("plugin").GetString()); + + JsonElement pluginFailed = Parse(lines[5]); + Assert.Equal("pluginFailed", pluginFailed.GetProperty("e").GetString()); + Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString()); + Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString()); + + JsonElement disconnected = Parse(lines[6]); Assert.Equal("disconnected", disconnected.GetProperty("e").GetString()); Assert.Equal("stopped", disconnected.GetProperty("reason").GetString()); - JsonElement exited = Parse(lines[5]); + JsonElement exited = Parse(lines[7]); Assert.Equal("exited", exited.GetProperty("e").GetString()); Assert.Equal(0, exited.GetProperty("code").GetInt32()); Assert.Equal("disposed", exited.GetProperty("reason").GetString()); @@ -80,6 +91,8 @@ public sealed class SessionStatusWriterTests writer.Started("s1"); writer.Connected("s1"); + writer.PluginLoaded("s1", "acdream.good"); + writer.PluginFailed("s1", "acdream.bad", "failed"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); @@ -164,9 +177,10 @@ public sealed class SessionStatusWriterTests /// credential material into this stream" contract: each event kind /// serializes EXACTLY its pinned property set — the shared envelope /// (v/e/t/sessionId) plus that event's own - /// named fields, nothing else. An extra property (a smuggled password, - /// or any other accidental field) fails this test by construction, - /// regardless of what value it carries. + /// named fields, nothing else. An extra credential-shaped or otherwise + /// accidental property fails this test by construction. LA5's documented + /// pluginFailed.error diagnostic is the one free-text value and its + /// caller remains responsible for never appending session secrets. /// [Fact] public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse() @@ -183,11 +197,13 @@ public sealed class SessionStatusWriterTests 11, [new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)])); writer.EnteredWorld("bot", 0x50000001u, "Ready"); + writer.PluginLoaded("bot", "acdream.good"); + writer.PluginFailed("bot", "acdream.bad", "enable failed"); writer.Disconnected("bot", "stopped"); writer.Exited("bot", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); - Assert.Equal(6, lines.Length); + Assert.Equal(8, lines.Length); AssertExactProperties(lines[0], "v", "e", "t", "sessionId"); AssertExactProperties(lines[1], "v", "e", "t", "sessionId"); @@ -196,8 +212,11 @@ public sealed class SessionStatusWriterTests "v", "e", "t", "sessionId", "accountName", "slotCount", "characters"); AssertExactProperties( lines[3], "v", "e", "t", "sessionId", "characterId", "characterName"); - AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason"); - AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason"); + AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin"); + AssertExactProperties( + lines[5], "v", "e", "t", "sessionId", "plugin", "error"); + AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason"); + AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason"); // The nested characters[] entries are exact too — the exact shape a // password could otherwise be smuggled through. From d0a9c65d85103bca8b0ccb91b32d6905cc5c2d82 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 18:15:14 +0200 Subject: [PATCH 030/138] feat(launcher): Campaign LA add Avalonia desktop shell --- AcDream.slnx | 2 + docs/architecture/acdream-architecture.md | 19 +- .../Launching/LauncherProcessSupervisor.cs | 36 +- .../Launching/SessionConfigComposer.cs | 75 ++ .../Orchestration/ILauncherOrchestrator.cs | 87 ++ .../Orchestration/LauncherExecutableSet.cs | 52 + .../Orchestration/LauncherOrchestrator.cs | 1184 +++++++++++++++++ .../LauncherPlatformCapabilities.cs | 83 ++ .../Orchestration/LauncherStateSnapshot.cs | 85 ++ .../Profiles/LauncherProfileStore.cs | 135 +- .../Status/StatusFileTailer.cs | 18 +- src/AcDream.Launcher/AcDream.Launcher.csproj | 24 + src/AcDream.Launcher/App.axaml | 8 + src/AcDream.Launcher/App.axaml.cs | 66 + src/AcDream.Launcher/MainWindow.axaml | 346 +++++ src/AcDream.Launcher/MainWindow.axaml.cs | 41 + src/AcDream.Launcher/Program.cs | 14 + src/AcDream.Launcher/ViewModels/Commands.cs | 88 ++ .../ViewModels/IUiDispatcher.cs | 26 + .../ViewModels/LauncherSessionRowViewModel.cs | 47 + .../ViewModels/LauncherShellViewModel.cs | 31 + .../ViewModels/LauncherTreeNodeViewModel.cs | 90 ++ .../ViewModels/LauncherWindowViewModel.cs | 866 ++++++++++++ .../ViewModels/ObservableObject.cs | 27 + .../ProfileEditorDialogViewModel.cs | 196 +++ .../Launching/SessionConfigComposerTests.cs | 35 + .../LauncherOrchestratorTests.cs | 502 +++++++ .../Profiles/LauncherProfileStoreTests.cs | 54 + .../AcDream.Launcher.Tests.csproj | 25 + .../LauncherProjectBoundaryTests.cs | 77 ++ .../LauncherWindowViewModelTests.cs | 508 +++++++ 31 files changed, 4831 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs create mode 100644 src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs create mode 100644 src/AcDream.Launcher/AcDream.Launcher.csproj create mode 100644 src/AcDream.Launcher/App.axaml create mode 100644 src/AcDream.Launcher/App.axaml.cs create mode 100644 src/AcDream.Launcher/MainWindow.axaml create mode 100644 src/AcDream.Launcher/MainWindow.axaml.cs create mode 100644 src/AcDream.Launcher/Program.cs create mode 100644 src/AcDream.Launcher/ViewModels/Commands.cs create mode 100644 src/AcDream.Launcher/ViewModels/IUiDispatcher.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherSessionRowViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherShellViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherTreeNodeViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherWindowViewModel.cs create mode 100644 src/AcDream.Launcher/ViewModels/ObservableObject.cs create mode 100644 src/AcDream.Launcher/ViewModels/ProfileEditorDialogViewModel.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs create mode 100644 tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj create mode 100644 tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index 20f17f98..0771d163 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4696577b..56353d45 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -279,9 +279,22 @@ src/ tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); Runtime and App reference it directly; Headless reaches it transitively through Runtime (K0 guard: Headless declares exactly - one project reference); the external launcher (AcDream.Launcher.Core, - Campaign LA — under construction) references ONLY this project from - the game solution + one project reference) + + AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner + Profiles/ -> sole credential/profile document + CRUD owner + Launching/ -> config composition and supervised process seams + Status/ -> incremental host-status parsing/tailing + Orchestration/ -> immutable UI snapshots, typed actions, + capability gates, and running-session lifetime + -> references Platform only; no Avalonia or game-host dependency + + AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell + ViewModels/ -> thin MVVM projection over Launcher.Core + -> references Launcher.Core only (Platform transitively); it never owns + a second profile, process, status, or credential state graph + -> Linux launcher/probe/headless flows remain portable; graphical-client + actions are explicitly disabled until Modern Runtime Slice L resumes AcDream.Headless/ Linux/Windows no-window production host Program.cs -> CLI entry only diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index 0f7fcac1..dd0db580 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -2,13 +2,47 @@ using System.Runtime.ExceptionServices; namespace AcDream.Launcher.Core.Launching; +/// +/// Test seam for one supervised launcher child. The Avalonia orchestration +/// layer owns this interface through a factory and never constructs or drives +/// directly. +/// +public interface ILauncherProcessSupervisor : IDisposable +{ + LauncherSessionState State { get; } + + int? ExitCode { get; } + + event EventHandler? StateChanged; + + void Start(LauncherProcessSpec spec, string? password); + + void Stop(TimeSpan timeout); +} + +public interface ILauncherProcessSupervisorFactory +{ + ILauncherProcessSupervisor Create(); +} + +public sealed class LauncherProcessSupervisorFactory( + ILauncherChildProcessFactory? childProcessFactory = null) + : ILauncherProcessSupervisorFactory +{ + private readonly ILauncherChildProcessFactory _childProcessFactory = + childProcessFactory ?? new SystemChildProcessFactory(); + + public ILauncherProcessSupervisor Create() => + new LauncherProcessSupervisor(_childProcessFactory); +} + /// /// Spawns a host process (App/Headless), feeds the account password to /// its stdin then closes it, and supervises its lifetime (Campaign LA /// spec §3/§6). One supervisor instance owns exactly one child process /// for its lifetime — start a new supervisor per launched session. /// -public sealed class LauncherProcessSupervisor : IDisposable +public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor { private readonly ILauncherChildProcessFactory _factory; private readonly object _gate = new(); diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index 1b4349d2..419e2e2d 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -13,6 +13,64 @@ public sealed record ComposedSessionConfig( string StatusFilePath, SessionConfigDocument Document); +/// +/// Injectable composition/write seam used by the canonical launcher +/// orchestrator. Production delegates to ; +/// tests can capture the exact request without writing a file or starting a +/// client process. +/// +public interface ILauncherSessionConfigService +{ + ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null); + + ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId); +} + +public sealed class LauncherSessionConfigService : ILauncherSessionConfigService +{ + public ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) => + SessionConfigComposer.ComposeAndWrite( + server, + account, + character, + install, + paths, + sessionId, + loginCommandDelayMs); + + public ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) => + SessionConfigComposer.ComposeProbeAndWrite( + server, + account, + install, + paths, + sessionId); +} + /// /// Builds the per-launch from a /// profile character + install record (Campaign LA spec §6). Passwords @@ -187,6 +245,23 @@ public static class SessionConfigComposer sessionId, loginCommandDelayMs); + return Write(composed); + } + + /// Probe counterpart to . It + /// writes the pinned mode: "probe" document and never includes + /// a character selector, policy, plugin set, login commands, or password. + /// + public static ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) => + Write(ComposeProbe(server, account, install, paths, sessionId)); + + private static ComposedSessionConfig Write(ComposedSessionConfig composed) + { string? directory = Path.GetDirectoryName(composed.ConfigFilePath); if (!string.IsNullOrEmpty(directory)) { diff --git a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs new file mode 100644 index 00000000..c2003b95 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs @@ -0,0 +1,87 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// Canonical state/mutation surface projected by the Avalonia launcher. The UI +/// never owns a second profile document, process map, status tail, or launch +/// transaction; it asks for immutable snapshots and sends typed mutations here. +/// +public interface ILauncherOrchestrator : IDisposable +{ + event EventHandler? StateChanged; + + void LoadProfiles(); + + LauncherStateSnapshot GetSnapshot(); + + LauncherCapability GetLaunchCapability(LaunchMode mode); + + LauncherCapability GetProbeCapability(string serverName, string accountName); + + void SetInstallRecord(LauncherInstallRecord? installRecord); + + void AddServer(string name, string host, int port); + + void EditServer(string name, string newName, string newHost, int newPort); + + void RemoveServer(string name); + + void AddAccount(string serverName, string accountName, string password); + + void EditAccount( + string serverName, + string accountName, + string newAccountName, + string? newPassword); + + void RemoveAccount(string serverName, string accountName); + + void AddCharacter( + string serverName, + string accountName, + string characterName, + string? characterId); + + void EditCharacterIdentity( + string serverName, + string accountName, + string characterName, + string newCharacterName, + string? newCharacterId); + + void UpdateCharacterSettings( + string serverName, + string accountName, + string characterName, + LaunchMode launchMode, + IReadOnlyList plugins, + IReadOnlyList loginCommands); + + void RemoveCharacter( + string serverName, + string accountName, + string characterName); + + Task LaunchAsync( + string serverName, + string accountName, + string characterName, + LaunchMode mode, + CancellationToken cancellationToken = default); + + Task ProbeAsync( + string serverName, + string accountName, + CancellationToken cancellationToken = default); + + Task StopSessionAsync( + string sessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default); + + void PollStatus(); + + void ClearFinishedSessions(); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs new file mode 100644 index 00000000..c3a4fa31 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -0,0 +1,52 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// Host executable paths supplied by the current installation. LA10 will +/// resolve these from the versioned app/current pointer; LA4 keeps the +/// mapping injectable and host-agnostic. +/// +public sealed record LauncherExecutableSet( + string GraphicalHostPath, + string HeadlessHostPath, + string? WorkingDirectory = null) +{ + public LauncherProcessSpec CreatePlaySpec( + LaunchMode mode, + string configFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + + return mode == LaunchMode.Headless + ? new LauncherProcessSpec( + HeadlessHostPath, + ["--config", configFilePath], + WorkingDirectory) + : new LauncherProcessSpec( + GraphicalHostPath, + ["--session-config", configFilePath], + WorkingDirectory); + } + + public LauncherProcessSpec CreateProbeSpec(string configFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + return new LauncherProcessSpec( + HeadlessHostPath, + ["--config", configFilePath], + WorkingDirectory); + } + + public static LauncherExecutableSet FromDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + string fullDirectory = Path.GetFullPath(directory); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return new LauncherExecutableSet( + Path.Combine(fullDirectory, "AcDream.App" + executableSuffix), + Path.Combine(fullDirectory, "acdream-headless" + executableSuffix), + fullDirectory); + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs new file mode 100644 index 00000000..fe9a426f --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -0,0 +1,1184 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Status; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// The one launcher-side state/orchestration owner. It owns the profile store, +/// config composition transaction, supervised child set, status tails, roster +/// folding, and platform capability gates. Avalonia receives immutable, +/// credential-free snapshots and sends typed commands back through +/// . +/// +public sealed class LauncherOrchestrator : ILauncherOrchestrator +{ + private const string FirstRunRequired = + "Client content is not configured. Complete the first-run setup before launching."; + + private readonly object _gate = new(); + private readonly LauncherProfileStore _profileStore; + private readonly ApplicationPathSet _paths; + private readonly LauncherExecutableSet _executables; + private readonly LauncherPlatformCapabilities _platform; + private readonly ILauncherSessionConfigService _configService; + private readonly ILauncherProcessSupervisorFactory _supervisorFactory; + private readonly IStatusEventSourceFactory _statusSourceFactory; + private readonly Func _sessionIdFactory; + private readonly List _activities = []; + + private LauncherInstallRecord? _installRecord; + private bool _disposed; + + public LauncherOrchestrator( + LauncherProfileStore profileStore, + ApplicationPathSet paths, + LauncherExecutableSet executables, + LauncherInstallRecord? installRecord = null, + LauncherPlatformCapabilities? platform = null, + ILauncherSessionConfigService? configService = null, + ILauncherProcessSupervisorFactory? supervisorFactory = null, + IStatusEventSourceFactory? statusSourceFactory = null, + Func? sessionIdFactory = null) + { + _profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore)); + _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + _executables = executables ?? throw new ArgumentNullException(nameof(executables)); + _installRecord = installRecord; + _platform = platform ?? LauncherPlatformCapabilities.Detect(); + _configService = configService ?? new LauncherSessionConfigService(); + _supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory(); + _statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory(); + _sessionIdFactory = sessionIdFactory ?? CreateSessionId; + } + + public event EventHandler? StateChanged; + + public void LoadProfiles() + { + lock (_gate) + { + ThrowIfDisposed(); + _profileStore.Load(); + } + + RaiseStateChanged(); + } + + public LauncherStateSnapshot GetSnapshot() + { + lock (_gate) + { + ThrowIfDisposed(); + + LauncherServerSnapshot[] servers = _profileStore.Document.Servers + .Select(CreateServerSnapshotLocked) + .ToArray(); + LauncherSessionSnapshot[] sessions = _activities + .OrderByDescending(activity => activity.CreatedAt) + .Select(activity => activity.ToSnapshot()) + .ToArray(); + + return new LauncherStateSnapshot( + servers, + sessions, + _platform, + _installRecord is not null, + _installRecord is null + ? FirstRunRequired + : "Client content paths are configured."); + } + } + + public LauncherCapability GetLaunchCapability(LaunchMode mode) + { + LauncherCapability platformCapability = _platform.ForLaunchMode(mode); + if (!platformCapability.IsAvailable) + { + return platformCapability; + } + + lock (_gate) + { + ThrowIfDisposed(); + return _installRecord is null + ? LauncherCapability.Unavailable(FirstRunRequired) + : LauncherCapability.Available; + } + } + + public LauncherCapability GetProbeCapability(string serverName, string accountName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + ArgumentException.ThrowIfNullOrWhiteSpace(accountName); + + LauncherCapability platformCapability = + _platform.ForLaunchMode(LaunchMode.Headless); + if (!platformCapability.IsAvailable) + { + return platformCapability; + } + + lock (_gate) + { + ThrowIfDisposed(); + _ = FindAccountLocked(serverName, accountName); + if (_installRecord is null) + { + return LauncherCapability.Unavailable(FirstRunRequired); + } + + ManagedActivity? active = FindActiveActivityLocked(serverName, accountName); + return active is null + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + $"Stop the running {active.Kind.ToString().ToLowerInvariant()} " + + "for this account before refreshing its characters."); + } + } + + public void SetInstallRecord(LauncherInstallRecord? installRecord) + { + lock (_gate) + { + ThrowIfDisposed(); + _installRecord = installRecord; + } + + RaiseStateChanged(); + } + + public void AddServer(string name, string host, int port) => + MutateProfiles(() => _profileStore.AddServer(name, host, port)); + + public void EditServer(string name, string newName, string newHost, int newPort) => + MutateProfiles(() => + { + EnsureServerIdleLocked(name); + _profileStore.EditServer( + name, + newName: newName, + newHost: newHost, + newPort: newPort); + }); + + public void RemoveServer(string name) => + MutateProfiles(() => + { + EnsureServerIdleLocked(name); + _profileStore.RemoveServer(name); + }); + + public void AddAccount(string serverName, string accountName, string password) => + MutateProfiles(() => + _profileStore.AddAccount(serverName, accountName, password)); + + public void EditAccount( + string serverName, + string accountName, + string newAccountName, + string? newPassword) => + MutateProfiles(() => + { + EnsureAccountIdleLocked(serverName, accountName); + _profileStore.EditAccount( + serverName, + accountName, + newAccount: newAccountName, + newPassword: newPassword); + }); + + public void RemoveAccount(string serverName, string accountName) => + MutateProfiles(() => + { + EnsureAccountIdleLocked(serverName, accountName); + _profileStore.RemoveAccount(serverName, accountName); + }); + + public void AddCharacter( + string serverName, + string accountName, + string characterName, + string? characterId) => + MutateProfiles(() => + _profileStore.AddCharacter( + serverName, + accountName, + characterName, + characterId)); + + public void EditCharacterIdentity( + string serverName, + string accountName, + string characterName, + string newCharacterName, + string? newCharacterId) => + MutateProfiles(() => + { + EnsureCharacterIdleLocked(serverName, accountName, characterName); + _profileStore.EditCharacter( + serverName, + accountName, + characterName, + newName: newCharacterName, + newId: newCharacterId); + }); + + public void UpdateCharacterSettings( + string serverName, + string accountName, + string characterName, + LaunchMode launchMode, + IReadOnlyList plugins, + IReadOnlyList loginCommands) => + MutateProfiles(() => + _profileStore.EditCharacter( + serverName, + accountName, + characterName, + launchMode: launchMode, + plugins: plugins, + loginCommands: loginCommands)); + + public void RemoveCharacter( + string serverName, + string accountName, + string characterName) => + MutateProfiles(() => + { + EnsureCharacterIdleLocked(serverName, accountName, characterName); + _profileStore.RemoveCharacter(serverName, accountName, characterName); + }); + + public Task LaunchAsync( + string serverName, + string accountName, + string characterName, + LaunchMode mode, + CancellationToken cancellationToken = default) + { + LauncherCapability capability = GetLaunchCapability(mode); + if (!capability.IsAvailable) + { + throw new LauncherOperationException(capability.Reason ?? "Launch is unavailable."); + } + + StartRequest request; + lock (_gate) + { + ThrowIfDisposed(); + ServerProfile server = FindServerLocked(serverName); + AccountProfile account = FindAccountLocked(serverName, accountName); + CharacterProfile character = FindCharacterLocked( + serverName, + accountName, + characterName); + LauncherInstallRecord install = _installRecord + ?? throw new LauncherOperationException(FirstRunRequired); + + string sessionId = ReserveSessionIdLocked(); + var activity = new ManagedActivity( + sessionId, + LauncherActivityKind.Play, + server.Name, + account.Account, + character.Name, + mode, + "Preparing session configuration…"); + _activities.Add(activity); + + request = new StartRequest( + activity, + CloneServer(server), + CloneAccountWithoutCharacters(account), + CloneCharacter(character, mode), + install, + account.Password, + isProbe: false, + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); + activity.StartCancellation = request.Cancellation; + } + + RaiseStateChanged(); + return StartActivityAsync(request); + } + + public Task ProbeAsync( + string serverName, + string accountName, + CancellationToken cancellationToken = default) + { + LauncherCapability capability = GetProbeCapability(serverName, accountName); + if (!capability.IsAvailable) + { + throw new LauncherOperationException( + capability.Reason ?? "Character refresh is unavailable."); + } + + StartRequest request; + lock (_gate) + { + ThrowIfDisposed(); + + // Repeat the active-account check while reserving the activity so + // two concurrent probes cannot both pass the public capability + // query and then start for the same account. + if (FindActiveActivityLocked(serverName, accountName) is not null) + { + throw new LauncherOperationException( + "A session or character refresh is already running for this account."); + } + + ServerProfile server = FindServerLocked(serverName); + AccountProfile account = FindAccountLocked(serverName, accountName); + LauncherInstallRecord install = _installRecord + ?? throw new LauncherOperationException(FirstRunRequired); + + string sessionId = ReserveSessionIdLocked(); + var activity = new ManagedActivity( + sessionId, + LauncherActivityKind.Probe, + server.Name, + account.Account, + characterName: null, + launchMode: null, + "Preparing character refresh…"); + _activities.Add(activity); + + request = new StartRequest( + activity, + CloneServer(server), + CloneAccountWithoutCharacters(account), + character: null, + install, + account.Password, + isProbe: true, + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); + activity.StartCancellation = request.Cancellation; + } + + RaiseStateChanged(); + return StartActivityAsync(request); + } + + public async Task StopSessionAsync( + string sessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (timeout < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + ILauncherProcessSupervisor? supervisor; + CancellationTokenSource? startCancellation; + lock (_gate) + { + ThrowIfDisposed(); + ManagedActivity activity = FindActivityLocked(sessionId); + if (!activity.IsActive) + { + return; + } + + activity.State = LauncherActivityState.Stopping; + activity.Status = "Stopping session…"; + supervisor = activity.Supervisor; + startCancellation = activity.StartCancellation; + } + + RaiseStateChanged(); + cancellationToken.ThrowIfCancellationRequested(); + startCancellation?.Cancel(); + + if (supervisor is null) + { + return; + } + + try + { + await Task.Run( + () => supervisor.Stop(timeout), + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lock (_gate) + { + ManagedActivity activity = FindActivityLocked(sessionId); + activity.Error = SafeError("Could not stop the session", ex, secret: null); + activity.Status = activity.Error; + } + + RaiseStateChanged(); + throw new LauncherOperationException( + SafeError("Could not stop the session", ex, secret: null)); + } + } + + public void PollStatus() + { + ManagedActivity[] activities; + lock (_gate) + { + ThrowIfDisposed(); + activities = _activities + .Where(activity => activity.StatusSource is not null) + .ToArray(); + } + + bool changed = false; + foreach (ManagedActivity activity in activities) + { + IReadOnlyList events; + try + { + lock (activity.StatusReadGate) + { + events = activity.StatusSource!.ReadNewEvents(); + } + } + catch (Exception ex) + { + lock (_gate) + { + if (_activities.Contains(activity)) + { + activity.Error = SafeError( + "Could not read the host status stream", + ex, + secret: null); + changed = true; + } + } + + continue; + } + + foreach (StatusEvent statusEvent in events) + { + ApplyStatusEvent(activity, statusEvent); + changed = true; + } + } + + if (changed) + { + RaiseStateChanged(); + } + } + + public void ClearFinishedSessions() + { + ManagedActivity[] removed; + lock (_gate) + { + ThrowIfDisposed(); + removed = _activities.Where(activity => !activity.IsActive).ToArray(); + foreach (ManagedActivity activity in removed) + { + _activities.Remove(activity); + } + } + + foreach (ManagedActivity activity in removed) + { + DisposeActivity(activity); + } + + if (removed.Length > 0) + { + RaiseStateChanged(); + } + } + + public void Dispose() + { + ManagedActivity[] activities; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + activities = _activities.ToArray(); + _activities.Clear(); + } + + foreach (ManagedActivity activity in activities) + { + DisposeActivity(activity); + } + } + + private async Task StartActivityAsync(StartRequest request) + { + try + { + await Task.Run( + () => StartActivityCore(request), + CancellationToken.None) + .ConfigureAwait(false); + lock (_gate) + { + return request.Activity.ToSnapshot(); + } + } + finally + { + request.Password = null; + lock (_gate) + { + if (ReferenceEquals( + request.Activity.StartCancellation, + request.Cancellation)) + { + request.Activity.StartCancellation = null; + } + } + + request.Cancellation.Dispose(); + } + } + + private void StartActivityCore(StartRequest request) + { + ILauncherProcessSupervisor? supervisor = null; + string? password = request.Password; + try + { + request.Cancellation.Token.ThrowIfCancellationRequested(); + + ComposedSessionConfig composed = request.IsProbe + ? _configService.ComposeProbeAndWrite( + request.Server, + request.Account, + request.Install, + _paths, + request.Activity.SessionId) + : _configService.ComposeAndWrite( + request.Server, + request.Account, + request.Character!, + request.Install, + _paths, + request.Activity.SessionId); + + request.Cancellation.Token.ThrowIfCancellationRequested(); + + supervisor = _supervisorFactory.Create(); + EventHandler stateHandler = + (_, state) => ApplySupervisorState(request.Activity, state); + supervisor.StateChanged += stateHandler; + IStatusEventSource statusSource = + _statusSourceFactory.Create(composed.StatusFilePath); + + lock (_gate) + { + request.Activity.Supervisor = supervisor; + request.Activity.SupervisorStateHandler = stateHandler; + request.Activity.StatusSource = statusSource; + request.Activity.Status = "Starting host process…"; + } + + RaiseStateChanged(); + request.Cancellation.Token.ThrowIfCancellationRequested(); + + LauncherProcessSpec processSpec = request.IsProbe + ? _executables.CreateProbeSpec(composed.ConfigFilePath) + : _executables.CreatePlaySpec( + request.Activity.LaunchMode!.Value, + composed.ConfigFilePath); + supervisor.Start(processSpec, password); + + request.Password = null; + password = null; + + if (request.Cancellation.IsCancellationRequested) + { + TryStop(supervisor); + request.Cancellation.Token.ThrowIfCancellationRequested(); + } + } + catch (OperationCanceledException) + { + if (supervisor is not null) + { + TryStop(supervisor); + } + + lock (_gate) + { + request.Activity.State = LauncherActivityState.Cancelled; + request.Activity.Status = "Operation cancelled."; + request.Activity.Error = null; + } + + RaiseStateChanged(); + throw; + } + catch (Exception ex) + { + string message = SafeError( + request.IsProbe + ? "Could not refresh characters" + : "Could not launch the client", + ex, + password); + lock (_gate) + { + request.Activity.State = LauncherActivityState.Failed; + request.Activity.Status = message; + request.Activity.Error = message; + } + + RaiseStateChanged(); + throw new LauncherOperationException(message); + } + finally + { + request.Password = null; + } + } + + private void ApplySupervisorState( + ManagedActivity activity, + LauncherSessionState processState) + { + try + { + lock (_gate) + { + if (!_activities.Contains(activity)) + { + return; + } + + switch (processState) + { + case LauncherSessionState.Starting: + if (activity.State == LauncherActivityState.Starting) + { + activity.Status = "Starting host process…"; + } + break; + case LauncherSessionState.Running: + if (activity.State is LauncherActivityState.Starting) + { + activity.State = LauncherActivityState.Running; + activity.Status = "Host process running; waiting for connection…"; + } + break; + case LauncherSessionState.Exited: + activity.ExitCode = activity.Supervisor?.ExitCode; + if (activity.State is not ( + LauncherActivityState.Failed + or LauncherActivityState.Cancelled)) + { + activity.State = LauncherActivityState.Exited; + activity.Status = activity.ExitCode is int code + ? $"Host process exited with code {code}." + : "Host process exited."; + } + break; + } + } + + RaiseStateChanged(); + } + catch + { + // Process lifecycle callbacks are observational. A presentation + // subscriber or disposal race must never throw back through the + // supervised child process's Exited event. + } + } + + private void ApplyStatusEvent(ManagedActivity activity, StatusEvent statusEvent) + { + lock (_gate) + { + if (!_activities.Contains(activity)) + { + return; + } + + if (!string.Equals( + statusEvent.SessionId, + activity.SessionId, + StringComparison.Ordinal)) + { + activity.Error = "Ignored a status event for a different session id."; + return; + } + + switch (statusEvent) + { + case StartedStatusEvent: + activity.Status = "Host started."; + break; + case ConnectedStatusEvent: + if (activity.State != LauncherActivityState.Stopping) + { + activity.State = LauncherActivityState.Connected; + } + activity.Status = "Connected; waiting for character roster…"; + break; + case CharacterListStatusEvent roster: + ApplyRosterLocked(activity, roster); + break; + case EnteredWorldStatusEvent enteredWorld: + if (activity.State != LauncherActivityState.Stopping) + { + activity.State = LauncherActivityState.InWorld; + } + activity.Status = $"In world as {enteredWorld.CharacterName}."; + break; + case PluginLoadedStatusEvent loaded: + activity.Status = $"Plugin loaded: {loaded.Plugin}."; + break; + case PluginFailedStatusEvent failed: + activity.Error = $"Plugin failed: {failed.Plugin}: {failed.Error}"; + activity.Status = activity.Error; + break; + case DisconnectedStatusEvent disconnected: + if (activity.State != LauncherActivityState.Stopping) + { + activity.State = LauncherActivityState.Disconnected; + } + activity.Status = $"Disconnected: {disconnected.Reason}."; + break; + case ExitedStatusEvent exited: + activity.State = LauncherActivityState.Exited; + activity.ExitCode = exited.Code; + activity.Status = $"Exited: {exited.Reason} (code {exited.Code})."; + break; + case MalformedStatusEvent malformed: + activity.Error = $"Malformed host status event: {malformed.Error}"; + break; + case UnknownStatusEvent unknown: + activity.Status = string.IsNullOrWhiteSpace(unknown.E) + ? "Ignored an unreadable host status event." + : $"Ignored unknown host event '{unknown.E}'."; + break; + } + } + } + + private void ApplyRosterLocked( + ManagedActivity activity, + CharacterListStatusEvent roster) + { + if (!string.Equals( + roster.AccountName, + activity.AccountName, + StringComparison.Ordinal)) + { + activity.Error = + "Ignored a character roster whose account did not match the launched account."; + return; + } + + try + { + _profileStore.MergeRoster( + activity.ServerName, + activity.AccountName, + roster.Characters + .Select(character => new CharacterRosterEntry( + character.Id, + character.Name, + character.SecondsGreyedOut)) + .ToArray()); + _profileStore.Save(); + activity.Status = roster.Characters.Count == 1 + ? "Character roster refreshed: 1 character." + : $"Character roster refreshed: {roster.Characters.Count} characters."; + } + catch (Exception ex) + { + activity.Error = SafeError( + "Could not save the refreshed character roster", + ex, + secret: null); + activity.Status = activity.Error; + } + } + + private LauncherServerSnapshot CreateServerSnapshotLocked(ServerProfile server) + { + LauncherAccountSnapshot[] accounts = server.Accounts + .Select(account => CreateAccountSnapshotLocked(server, account)) + .ToArray(); + return new LauncherServerSnapshot( + server.Name, + server.Host, + server.Port, + accounts); + } + + private LauncherAccountSnapshot CreateAccountSnapshotLocked( + ServerProfile server, + AccountProfile account) + { + ManagedActivity? active = FindActiveActivityLocked(server.Name, account.Account); + LauncherCharacterSnapshot[] characters = account.Characters + .Select(character => + { + ManagedActivity? characterActivity = _activities + .LastOrDefault(candidate => + candidate.IsActive + && candidate.Kind == LauncherActivityKind.Play + && string.Equals( + candidate.ServerName, + server.Name, + StringComparison.Ordinal) + && string.Equals( + candidate.AccountName, + account.Account, + StringComparison.Ordinal) + && string.Equals( + candidate.CharacterName, + character.Name, + StringComparison.Ordinal)); + return new LauncherCharacterSnapshot( + server.Name, + account.Account, + character.Name, + character.Id, + character.LaunchMode, + character.Plugins.ToArray(), + character.LoginCommands.ToArray(), + characterActivity is not null, + characterActivity?.Status ?? "Not running"); + }) + .ToArray(); + + return new LauncherAccountSnapshot( + server.Name, + account.Account, + characters, + active is not null, + active?.Status ?? "Idle"); + } + + private void MutateProfiles(Action mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + lock (_gate) + { + ThrowIfDisposed(); + mutation(); + _profileStore.Save(); + } + + RaiseStateChanged(); + } + + private ServerProfile FindServerLocked(string serverName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + return _profileStore.Document.Servers.Find(server => + string.Equals(server.Name, serverName, StringComparison.Ordinal)) + ?? throw new LauncherProfileException($"No server named '{serverName}'."); + } + + private AccountProfile FindAccountLocked(string serverName, string accountName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accountName); + ServerProfile server = FindServerLocked(serverName); + return server.Accounts.Find(account => + string.Equals(account.Account, accountName, StringComparison.Ordinal)) + ?? throw new LauncherProfileException( + $"No account '{accountName}' on server '{serverName}'."); + } + + private CharacterProfile FindCharacterLocked( + string serverName, + string accountName, + string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + AccountProfile account = FindAccountLocked(serverName, accountName); + return account.Characters.Find(character => + string.Equals(character.Name, characterName, StringComparison.Ordinal)) + ?? throw new LauncherProfileException( + $"No character '{characterName}' on account '{accountName}'."); + } + + private ManagedActivity FindActivityLocked(string sessionId) => + _activities.Find(activity => + string.Equals(activity.SessionId, sessionId, StringComparison.Ordinal)) + ?? throw new LauncherOperationException($"No launcher session '{sessionId}'."); + + private ManagedActivity? FindActiveActivityLocked( + string serverName, + string accountName) => + _activities.LastOrDefault(activity => + activity.IsActive + && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal) + && string.Equals(activity.AccountName, accountName, StringComparison.Ordinal)); + + private void EnsureServerIdleLocked(string serverName) + { + if (_activities.Any(activity => + activity.IsActive + && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal))) + { + throw new LauncherOperationException( + "Stop this server's running launcher sessions before editing or removing it."); + } + } + + private void EnsureAccountIdleLocked(string serverName, string accountName) + { + if (FindActiveActivityLocked(serverName, accountName) is not null) + { + throw new LauncherOperationException( + "Stop this account's running launcher session before editing or removing it."); + } + } + + private void EnsureCharacterIdleLocked( + string serverName, + string accountName, + string characterName) + { + if (_activities.Any(activity => + activity.IsActive + && activity.Kind == LauncherActivityKind.Play + && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal) + && string.Equals(activity.AccountName, accountName, StringComparison.Ordinal) + && string.Equals(activity.CharacterName, characterName, StringComparison.Ordinal))) + { + throw new LauncherOperationException( + "Stop this character's running session before editing or removing it."); + } + } + + private string ReserveSessionIdLocked() + { + string sessionId = _sessionIdFactory(); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (_activities.Any(activity => string.Equals( + activity.SessionId, + sessionId, + StringComparison.Ordinal))) + { + throw new LauncherOperationException( + $"The launcher generated duplicate session id '{sessionId}'."); + } + + return sessionId; + } + + private static ServerProfile CloneServer(ServerProfile source) => + new() + { + Name = source.Name, + Host = source.Host, + Port = source.Port, + }; + + private static AccountProfile CloneAccountWithoutCharacters(AccountProfile source) => + new() + { + Account = source.Account, + // Composition needs only the public account name. Keep the + // credential exclusively in StartRequest.Password until the one + // supervisor stdin handoff, then clear that reference. + Password = string.Empty, + }; + + private static CharacterProfile CloneCharacter( + CharacterProfile source, + LaunchMode mode) => + new() + { + Name = source.Name, + Id = source.Id, + LaunchMode = mode, + Plugins = [.. source.Plugins], + LoginCommands = [.. source.LoginCommands], + }; + + private static string CreateSessionId() => + $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"; + + private static void TryStop(ILauncherProcessSupervisor supervisor) + { + try + { + supervisor.Stop(TimeSpan.FromSeconds(5)); + } + catch + { + // Cancellation cleanup is best-effort. The activity remains + // visibly Cancelled and never reports a successful launch. + } + } + + private static string SafeError(string prefix, Exception exception, string? secret) + { + string detail = exception.Message; + if (!string.IsNullOrEmpty(secret)) + { + detail = detail.Replace(secret, "[redacted]", StringComparison.Ordinal); + } + + return string.IsNullOrWhiteSpace(detail) + ? prefix + "." + : $"{prefix}: {detail}"; + } + + private void RaiseStateChanged() + { + Delegate[] subscribers = StateChanged?.GetInvocationList() ?? []; + foreach (Delegate subscriber in subscribers) + { + try + { + ((EventHandler)subscriber)(this, EventArgs.Empty); + } + catch + { + // This is an observation seam. A view that is closing or a + // faulty subscriber must not break process/session lifetime. + } + } + } + + private static void DisposeActivity(ManagedActivity activity) + { + activity.StartCancellation?.Cancel(); + activity.StartCancellation?.Dispose(); + activity.StartCancellation = null; + + if (activity.Supervisor is not null) + { + if (activity.SupervisorStateHandler is not null) + { + activity.Supervisor.StateChanged -= activity.SupervisorStateHandler; + } + + activity.Supervisor.Dispose(); + activity.Supervisor = null; + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed class ManagedActivity + { + public ManagedActivity( + string sessionId, + LauncherActivityKind kind, + string serverName, + string accountName, + string? characterName, + LaunchMode? launchMode, + string status) + { + SessionId = sessionId; + Kind = kind; + ServerName = serverName; + AccountName = accountName; + CharacterName = characterName; + LaunchMode = launchMode; + Status = status; + CreatedAt = DateTimeOffset.UtcNow; + } + + public string SessionId { get; } + + public LauncherActivityKind Kind { get; } + + public string ServerName { get; } + + public string AccountName { get; } + + public string? CharacterName { get; } + + public LaunchMode? LaunchMode { get; } + + public DateTimeOffset CreatedAt { get; } + + public LauncherActivityState State { get; set; } = LauncherActivityState.Starting; + + public string Status { get; set; } + + public int? ExitCode { get; set; } + + public string? Error { get; set; } + + public ILauncherProcessSupervisor? Supervisor { get; set; } + + public EventHandler? SupervisorStateHandler { get; set; } + + public IStatusEventSource? StatusSource { get; set; } + + public CancellationTokenSource? StartCancellation { get; set; } + + public object StatusReadGate { get; } = new(); + + public bool IsActive => State is not ( + LauncherActivityState.Exited + or LauncherActivityState.Failed + or LauncherActivityState.Cancelled); + + public LauncherSessionSnapshot ToSnapshot() => + new( + SessionId, + Kind, + ServerName, + AccountName, + CharacterName, + LaunchMode, + State, + Status, + ExitCode, + Error, + CreatedAt); + } + + private sealed class StartRequest( + ManagedActivity activity, + ServerProfile server, + AccountProfile account, + CharacterProfile? character, + LauncherInstallRecord install, + string password, + bool isProbe, + CancellationTokenSource cancellation) + { + public ManagedActivity Activity { get; } = activity; + + public ServerProfile Server { get; } = server; + + public AccountProfile Account { get; } = account; + + public CharacterProfile? Character { get; } = character; + + public LauncherInstallRecord Install { get; } = install; + + public string? Password { get; set; } = password; + + public bool IsProbe { get; } = isProbe; + + public CancellationTokenSource Cancellation { get; } = cancellation; + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs new file mode 100644 index 00000000..57be9979 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs @@ -0,0 +1,83 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +public readonly record struct LauncherCapability(bool IsAvailable, string? Reason) +{ + public static LauncherCapability Available { get; } = new(true, null); + + public static LauncherCapability Unavailable(string reason) => + new(false, reason); +} + +/// +/// Immutable platform row selected once at launcher startup. Campaign LA +/// ships the Avalonia launcher, profile editor, probes, and headless sessions +/// on Windows and Linux. Graphical client launches remain Windows-only until +/// Modern Runtime Slice L resumes from its parked L1 checkpoint. +/// +public sealed record LauncherPlatformCapabilities( + bool IsWindows, + bool IsLinux, + bool CanRunHeadless, + bool CanLaunchGraphicalClient, + string PlatformName, + string? GraphicalLaunchDisabledReason) +{ + public const string LinuxGraphicalLaunchDisabledReason = + "GUI launches require the Linux graphical client (Modern Runtime Slice L), " + + "which is parked at L1 and will resume later. The launcher, character " + + "probe, and headless sessions remain available on Linux."; + + public static LauncherPlatformCapabilities Detect() + { + if (OperatingSystem.IsWindows()) + { + return new LauncherPlatformCapabilities( + IsWindows: true, + IsLinux: false, + CanRunHeadless: true, + CanLaunchGraphicalClient: true, + PlatformName: "Windows", + GraphicalLaunchDisabledReason: null); + } + + if (OperatingSystem.IsLinux()) + { + return new LauncherPlatformCapabilities( + IsWindows: false, + IsLinux: true, + CanRunHeadless: true, + CanLaunchGraphicalClient: false, + PlatformName: "Linux", + GraphicalLaunchDisabledReason: LinuxGraphicalLaunchDisabledReason); + } + + return new LauncherPlatformCapabilities( + IsWindows: false, + IsLinux: false, + CanRunHeadless: false, + CanLaunchGraphicalClient: false, + PlatformName: "Unsupported", + GraphicalLaunchDisabledReason: + "Graphical client launches are supported on Windows. Linux support " + + "requires Modern Runtime Slice L."); + } + + public LauncherCapability ForLaunchMode(LaunchMode mode) + { + if (mode == LaunchMode.Headless) + { + return CanRunHeadless + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + "Headless launches are supported only on Windows and Linux."); + } + + return CanLaunchGraphicalClient + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + GraphicalLaunchDisabledReason + ?? "The graphical client is unavailable on this platform."); + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs new file mode 100644 index 00000000..918e1065 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs @@ -0,0 +1,85 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +public sealed record LauncherCharacterSnapshot( + string ServerName, + string AccountName, + string Name, + string? Id, + LaunchMode LaunchMode, + IReadOnlyList Plugins, + IReadOnlyList LoginCommands, + bool HasRunningSession, + string SessionStatus); + +/// +/// Password is deliberately absent. The account credential remains reachable +/// only inside and the transient +/// stdin handoff performed by . +/// +public sealed record LauncherAccountSnapshot( + string ServerName, + string AccountName, + IReadOnlyList Characters, + bool HasRunningActivity, + string ActivityStatus); + +public sealed record LauncherServerSnapshot( + string Name, + string Host, + int Port, + IReadOnlyList Accounts); + +public enum LauncherActivityKind +{ + Play, + Probe, +} + +public enum LauncherActivityState +{ + Starting, + Running, + Connected, + InWorld, + Disconnected, + Stopping, + Exited, + Failed, + Cancelled, +} + +public sealed record LauncherSessionSnapshot( + string SessionId, + LauncherActivityKind Kind, + string ServerName, + string AccountName, + string? CharacterName, + LaunchMode? LaunchMode, + LauncherActivityState State, + string Status, + int? ExitCode, + string? Error, + DateTimeOffset CreatedAt) +{ + public bool IsActive => State is not ( + LauncherActivityState.Exited + or LauncherActivityState.Failed + or LauncherActivityState.Cancelled); +} + +public sealed record LauncherStateSnapshot( + IReadOnlyList Servers, + IReadOnlyList Sessions, + LauncherPlatformCapabilities Platform, + bool IsInstallationReady, + string InstallationStatus); + +public sealed class LauncherOperationException : Exception +{ + public LauncherOperationException(string message) + : base(message) + { + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs index 25d30787..01717400 100644 --- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -325,13 +325,59 @@ public sealed class LauncherProfileStore server.Accounts.Remove(profile); } - // --- Character settings (roster-driven add/remove; user-edited settings) --- + // --- Character CRUD / user-owned settings -------------------------- /// - /// Edits the user-owned settings of an existing character row. There - /// is no manual add/remove for characters — the roster ( - /// ) is the only source of new rows, per - /// spec §5/§6. + /// Adds a manually configured character row. Normal operation discovers + /// characters through , but LA4's full in-UI + /// CRUD contract also lets a user create a cached row before a successful + /// probe (for example, to launch by a known character name while a server + /// is temporarily unavailable). A later roster merge remains + /// authoritative for the id/name pair and preserves these user settings. + /// + public CharacterProfile AddCharacter( + string serverName, + string account, + string characterName, + string? id = null, + LaunchMode launchMode = LaunchMode.GuiSelect, + IReadOnlyList? plugins = null, + IReadOnlyList? loginCommands = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + if (FindCharacter(profile, characterName) is not null) + { + throw new LauncherProfileException( + $"Character '{characterName}' already exists on account '{account}'."); + } + + string? normalizedId = NormalizeCharacterId(id); + if (normalizedId is not null + && profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId))) + { + throw new LauncherProfileException( + $"Character id '{normalizedId}' already exists on account '{account}'."); + } + + var character = new CharacterProfile + { + Name = characterName, + Id = normalizedId, + LaunchMode = launchMode, + Plugins = plugins is null ? [] : [.. plugins], + LoginCommands = loginCommands is null ? [] : [.. loginCommands], + }; + profile.Characters.Add(character); + return character; + } + + /// + /// Edits the identity cache and/or user-owned settings of an existing + /// character row. Passing an empty clears a + /// manually entered id so launches fall back to the character name. /// public void EditCharacter( string serverName, @@ -339,12 +385,42 @@ public sealed class LauncherProfileStore string characterName, LaunchMode? launchMode = null, IReadOnlyList? plugins = null, - IReadOnlyList? loginCommands = null) + IReadOnlyList? loginCommands = null, + string? newName = null, + string? newId = null) { ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); CharacterProfile character = FindCharacterOrThrow(profile, characterName); + if (newName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + if (!string.Equals(newName, character.Name, StringComparison.Ordinal) + && FindCharacter(profile, newName) is not null) + { + throw new LauncherProfileException( + $"Character '{newName}' already exists on account '{account}'."); + } + + character.Name = newName; + } + + if (newId is not null) + { + string? normalizedId = NormalizeCharacterId(newId); + if (normalizedId is not null + && profile.Characters.Any(candidate => + !ReferenceEquals(candidate, character) + && CharacterIdsEqual(candidate.Id, normalizedId))) + { + throw new LauncherProfileException( + $"Character id '{normalizedId}' already exists on account '{account}'."); + } + + character.Id = normalizedId; + } + if (launchMode is not null) { character.LaunchMode = launchMode.Value; @@ -361,6 +437,17 @@ public sealed class LauncherProfileStore } } + public void RemoveCharacter( + string serverName, + string account, + string characterName) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + CharacterProfile character = FindCharacterOrThrow(profile, characterName); + profile.Characters.Remove(character); + } + /// /// Folds a reported character roster into an account's /// (Campaign LA spec §3/§5/ @@ -456,20 +543,46 @@ public sealed class LauncherProfileStore $"No account '{account}' on server '{server.Name}'."); } + private static CharacterProfile? FindCharacter( + AccountProfile profile, + string characterName) => + profile.Characters.Find( + character => string.Equals( + character.Name, + characterName, + StringComparison.Ordinal)); + private static CharacterProfile FindCharacterOrThrow( AccountProfile profile, string characterName) { ArgumentException.ThrowIfNullOrWhiteSpace(characterName); - return profile.Characters.Find( - character => string.Equals( - character.Name, - characterName, - StringComparison.Ordinal)) + return FindCharacter(profile, characterName) ?? throw new LauncherProfileException( $"No character '{characterName}' on account '{profile.Account}'."); } + private static string? NormalizeCharacterId(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0) + { + throw new LauncherProfileException( + "Character id must be a non-zero hexadecimal value with a 0x prefix."); + } + + return CharacterIdFormat.ToHexString(parsed); + } + + private static bool CharacterIdsEqual(string? left, string? right) => + CharacterIdFormat.TryParse(left, out uint leftId) + && CharacterIdFormat.TryParse(right, out uint rightId) + && leftId == rightId; + private static void RequireValidPort(int port) { if (port is < 1 or > 65535) diff --git a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs index a37b4075..7450ee58 100644 --- a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs +++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs @@ -19,7 +19,23 @@ namespace AcDream.Launcher.Core.Status; /// One tailer instance owns one file's read position; construct a new /// one per session. /// -public sealed class StatusFileTailer +public interface IStatusEventSource +{ + IReadOnlyList ReadNewEvents(); +} + +/// Creates one independent status source per launched session. +public interface IStatusEventSourceFactory +{ + IStatusEventSource Create(string path); +} + +public sealed class StatusFileTailerFactory : IStatusEventSourceFactory +{ + public IStatusEventSource Create(string path) => new StatusFileTailer(path); +} + +public sealed class StatusFileTailer : IStatusEventSource { private readonly string _path; private long _position; diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj new file mode 100644 index 00000000..33b07cd5 --- /dev/null +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -0,0 +1,24 @@ + + + WinExe + acdream-launcher + AcDream.Launcher + net10.0 + enable + enable + latest + true + true + true + + + + + + + + + + + + diff --git a/src/AcDream.Launcher/App.axaml b/src/AcDream.Launcher/App.axaml new file mode 100644 index 00000000..3f6dbf49 --- /dev/null +++ b/src/AcDream.Launcher/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs new file mode 100644 index 00000000..75dd93fe --- /dev/null +++ b/src/AcDream.Launcher/App.axaml.cs @@ -0,0 +1,66 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.ViewModels; +using AcDream.Platform; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace AcDream.Launcher; + +public sealed partial class App : Application +{ + private LauncherOrchestrator? _orchestrator; + private LauncherWindowViewModel? _viewModel; + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); + LauncherInstallRecord? install = ResolveDevelopmentInstallRecord(); + _orchestrator = new LauncherOrchestrator( + profiles, + paths, + LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory), + install); + _viewModel = new LauncherWindowViewModel( + _orchestrator, + new AvaloniaUiDispatcher()); + _viewModel.Initialize(); + + desktop.MainWindow = new MainWindow + { + DataContext = _viewModel, + }; + desktop.Exit += OnDesktopExit; + } + + base.OnFrameworkInitializationCompleted(); + } + + private static LauncherInstallRecord? ResolveDevelopmentInstallRecord() + { + // LA9 owns persisted install discovery. LA4 accepts the existing + // developer environment pair at this one composition root so the + // launch/probe UI can be exercised before the first-run body lands. + string? datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? preparedAssetPath = Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH"); + return !string.IsNullOrWhiteSpace(datDirectory) + && !string.IsNullOrWhiteSpace(preparedAssetPath) + ? new LauncherInstallRecord(datDirectory, preparedAssetPath) + : null; + } + + private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) + { + _viewModel?.Dispose(); + _orchestrator?.Dispose(); + _viewModel = null; + _orchestrator = null; + } +} diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml new file mode 100644 index 00000000..cc63bb44 --- /dev/null +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -0,0 +1,346 @@ + + + + + + + + + + + + + + + + + + public TimeSpan AutoAdvanceOnBlockingReceive { get; set; } + /// + /// Keeps the default ACE script intact while allowing focused handshake + /// tests to enqueue messages before the valid ServerReady response. + /// + public bool AutoReplyServerReady { get; set; } = true; + public FakeAceTransport(VirtualClock? clock = null, LossyLink? link = null) { Clock = clock ?? new VirtualClock(); @@ -101,7 +107,12 @@ internal sealed class FakeAceTransport : IWorldSessionTransport case CharacterEnterWorld.EnterWorldRequestOpcode: // 0xF7C8 // Server replies CharacterEnterWorldServerReady (0xF7DF) — // WorldSession.EnterWorld blocks on this opcode. - Model.EnqueueGameMessage(BuildOpcodeOnlyBody(0xF7DFu), GameMessageGroup.UIQueue); + if (AutoReplyServerReady) + { + Model.EnqueueGameMessage( + BuildOpcodeOnlyBody(0xF7DFu), + GameMessageGroup.UIQueue); + } break; case CharacterLogOff.Opcode: // 0xF653 request (opcode + character id) // ACE echoes the opcode-only confirmation; WorldSession.Dispose @@ -142,6 +153,22 @@ internal sealed class FakeAceTransport : IWorldSessionTransport } } + /// + /// Enqueue and flush one model game message under the transport's model + /// lock. This is the race-free test seam for a server follower emitted + /// while the real WorldSession background receiver is active. + /// + public void EnqueueServerGameMessage( + byte[] body, + GameMessageGroup group) + { + lock (_gate) + { + Model.EnqueueGameMessage(body, group); + PumpServerLocked(); + } + } + /// /// N2 test hook: deliver raw bytes straight into the client's receive /// queue, bypassing both the model and the link. Used for late diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs index 0c817f0c..2d79544f 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs @@ -2,6 +2,7 @@ using System.Buffers.Binary; using System.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Transport; namespace AcDream.Core.Net.Tests.Transport; @@ -165,6 +166,105 @@ public sealed class FakeAceTransportTests Assert.Equal(0, transport.Model.CrcDropCount); } + [Fact] + public async Task PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep() + { + var fake = new FakeAceTransport(); + // Random(~13) yields 4, 92, 55, 54: at 50%, only the first + // post-arm inbound datagram (ServerReady) is dropped; the follower, + // recovered resend, and graceful-logoff confirmation all land. + var lossy = new LossyTransportDecorator( + fake, + dropPercent: 50, + seed: 13, + NetDropDirection.In); + var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + lossy) + { + TransportClockSource = + (fake.Clock.GetTimestamp, fake.Clock.Frequency), + }; + using var enterRequest = new ManualResetEventSlim(); + fake.Model.MessageDispatched += body => + { + if (ReadOpcode(body) + == CharacterEnterWorld.EnterWorldRequestOpcode) + { + enterRequest.Set(); + } + }; + + try + { + session.Connect( + FakeAceTransport.DefaultAccountName, + "testpassword", + TimeSpan.FromSeconds(5)); + session.StartCharacterSelectionReceive(); + + // Graphical selector frames continue before the user enters. + for (int i = 0; i < 3; i++) + { + fake.Clock.Advance(TimeSpan.FromMilliseconds(50)); + session.Tick(); + } + + Task gapDriver = Task.Run(() => + { + Assert.True(enterRequest.Wait(TimeSpan.FromSeconds(2))); + // This later sequenced packet passes the seeded loss gate, + // exposing the missing ServerReady and parking behind it. + fake.EnqueueServerGameMessage( + BuildServerMessage("post-ready follower"), + GameMessageGroup.UIQueue); + Assert.True(SpinWait.SpinUntil( + () => session.Transport?.Inbound.NakCount > 0, + TimeSpan.FromSeconds(2))); + + // No datagram follows this virtual-time edge. Recovery now + // requires paused EnterWorld's independent periodic sweep. + fake.Clock.Advance(TimeSpan.FromSeconds(1)); + }); + + session.EnterWorld(0, TimeSpan.FromSeconds(5)); + await gapDriver; + + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + Assert.Equal(1, lossy.InboundDropped); + Assert.True(session.Transport!.Stats.NaksSent > 0); + Assert.True(fake.Model.RetransmitsServed > 0); + Assert.Equal(0, session.Transport.Inbound.NakCount); + Assert.Equal(0, session.Transport.Outbound.PendingResendCount); + Assert.Equal( + new[] + { + CharacterEnterWorld.EnterWorldRequestOpcode, + CharacterEnterWorld.EnterWorldOpcode, + }, + fake.Model.DispatchedMessages.Select(ReadOpcode).ToArray()); + Assert.Equal(256, fake.Model.Crypto.Headroom); + Assert.Equal(0, fake.Model.Crypto.OrphanCount); + Assert.Equal(0, fake.Model.CrcDropCount); + Assert.False(fake.Model.IsTerminated); + } + finally + { + session.Dispose(); + } + + Assert.Equal(WorldSession.State.Disconnected, session.CurrentState); + Assert.True(fake.Model.IsTerminated); + Assert.Equal( + AceTerminationReason.PacketHeaderDisconnect, + fake.Model.TerminationReason); + Assert.Equal( + CharacterLogOff.Opcode, + ReadOpcode(fake.Model.DispatchedMessages[^1])); + Assert.Equal(256, fake.Model.Crypto.Headroom); + Assert.Equal(0, fake.Model.Crypto.OrphanCount); + } + private static uint ReadOpcode(byte[] messageBody) => BinaryPrimitives.ReadUInt32LittleEndian(messageBody); diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs index 04ac26b4..71d5f787 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -3,6 +3,7 @@ using System.Net; using System.Reflection; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Tests.Transport; namespace AcDream.Core.Net.Tests; @@ -91,11 +92,80 @@ public sealed class WorldSessionCharacterSelectionTests Assert.Equal(1u, current.SecondsGreyedOut); } + [Fact] + public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady() + { + var transport = new FakeAceTransport + { + AutoReplyServerReady = false, + }; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + int errors = 0; + session.CharacterErrorReceived += _ => errors++; + ConfigureSentinelThenServerReady(transport); + + session.Connect( + FakeAceTransport.DefaultAccountName, + "testpassword", + TimeSpan.FromSeconds(5)); + session.EnterWorld(0, TimeSpan.FromSeconds(5)); + + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + Assert.Equal(0, errors); + } + + [Fact] + public void PausedEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady() + { + var transport = new FakeAceTransport + { + AutoReplyServerReady = false, + }; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + transport); + int errors = 0; + session.CharacterErrorReceived += _ => errors++; + ConfigureSentinelThenServerReady(transport); + + session.Connect( + FakeAceTransport.DefaultAccountName, + "testpassword", + TimeSpan.FromSeconds(5)); + session.StartCharacterSelectionReceive(); + session.EnterWorld(0, TimeSpan.FromSeconds(5)); + + Assert.Equal(WorldSession.State.InWorld, session.CurrentState); + Assert.Equal(0, errors); + } + private static WorldSession CreateSession() => new( new IPEndPoint(IPAddress.Loopback, 9000), new NullTransport()); + private static void ConfigureSentinelThenServerReady( + FakeAceTransport transport) + { + transport.Model.MessageDispatched += body => + { + if (BinaryPrimitives.ReadUInt32LittleEndian(body) + != CharacterEnterWorld.EnterWorldRequestOpcode) + { + return; + } + + transport.Model.EnqueueGameMessage( + BuildCharacterError(CharacterError.Code.NumErrors), + GameMessageGroup.UIQueue); + transport.Model.EnqueueGameMessage( + BitConverter.GetBytes(0xF7DFu), + GameMessageGroup.UIQueue); + }; + } + private static byte[] BuildRoster(uint secondsGreyedOut) { var writer = new PacketWriter(96); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 124190b3..2f0fe01d 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -8,6 +8,18 @@ namespace AcDream.Runtime.Tests.Session; public sealed class LiveSessionControllerTests { + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => _timestamp; + + public void Advance(TimeSpan elapsed) => + _timestamp += elapsed.Ticks; + } + private sealed class TestTransport : IWorldSessionTransport { public void Send(ReadOnlySpan datagram) { } @@ -466,7 +478,8 @@ public sealed class LiveSessionControllerTests var calls = new List(); var operations = new TestOperations(calls); var host = new TestHost(calls); - var controller = new LiveSessionController(operations); + var time = new ManualTimeProvider(); + var controller = new LiveSessionController(operations, time); Assert.Equal( LiveSessionStartStatus.AwaitingCharacterSelection, controller.Start(LiveOptions(awaitSelection: true), host).Status); @@ -499,8 +512,21 @@ public sealed class LiveSessionControllerTests // ACE may send no response for an unknown guid. The synchronous // command has already returned and does not gate later commands. Assert.True(controller.Highlight(generation, 0x50000002u).Accepted); + Assert.Equal( + RuntimeCommandStatus.Rejected, + controller.Restore(generation).Status); controller.Tick(); Assert.Equal(1, operations.TickCount); + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + controller.Tick(); + Assert.Equal( + RuntimeCharacterSelectionOperation.None, + controller.CharacterSelection.Snapshot.Operation); + Assert.True(controller.CharacterSelection.Snapshot.Buttons.CanRestore); + Assert.True(controller.Restore(generation).Accepted); + Assert.Equal( + [0x50000001u, 0x50000002u], + operations.RestoreRequests); Assert.False(controller.IsInWorld); } diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs index 2b5f43e1..c8b79b30 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs @@ -23,6 +23,18 @@ public sealed class RuntimeCharacterSelectionStateTests onDelta(delta); } + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => _timestamp; + + public void Advance(TimeSpan elapsed) => + _timestamp += elapsed.Ticks; + } + [Fact] public void ApplyRoster_PortsRetailOrderFallbackAndDisabledButtonMatrix() { @@ -154,9 +166,10 @@ public sealed class RuntimeCharacterSelectionStateTests } [Fact] - public void RestoreResponse_FromSupersededRequestCannotCompleteTheCurrentRequest() + public void DelayedFlagOnlyRestoreResponse_CannotCompleteNewerRequest() { - using var state = new RuntimeCharacterSelectionState(); + var time = new ManualTimeProvider(); + using var state = new RuntimeCharacterSelectionState(time); state.Begin(new RuntimeGenerationToken(12)); state.ApplyRoster(Roster( new(0x50000001u, "First", 1u), @@ -164,18 +177,55 @@ public sealed class RuntimeCharacterSelectionStateTests Assert.True(state.TryBeginRestore(out _)); Assert.True(state.TryHighlight(0x50000002u)); + Assert.False(state.TryBeginRestore(out _)); + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + Assert.True(state.SweepRestoreCorrelation()); Assert.True(state.TryBeginRestore(out _)); + long revision = state.Snapshot.Revision; state.ApplyRestore(new CharacterRestore.Parsed( - 1u, - 0x50000001u, - "First", - 0u)); + VerificationFlag: 2u, + Guid: null, + Name: null, + SecondsGreyedOut: null)); Assert.Equal( RuntimeCharacterSelectionOperation.RestoreRequested, state.Snapshot.Operation); + Assert.Equal(0x50000002u, state.Snapshot.LastRestoreRequestedCharacterId); + Assert.Equal(revision, state.Snapshot.Revision); Assert.True(state.View.TryGet(0x50000001u, out var first)); Assert.True(first.IsPendingDelete); + Assert.True(state.View.TryGet(0x50000002u, out var second)); + Assert.True(second.IsPendingDelete); + Assert.False(state.Snapshot.Buttons.CanRestore); + } + + [Fact] + public void NoReplyRestoreTimeout_ReleasesOnlyRestoreGate() + { + var time = new ManualTimeProvider(); + using var state = new RuntimeCharacterSelectionState(time); + state.Begin(new RuntimeGenerationToken(13)); + state.ApplyRoster(Roster( + new(0x50000001u, "Pending", 1u), + new(0x50000002u, "Ready", 0u))); + + Assert.True(state.TryHighlight(0x50000001u)); + Assert.True(state.TryBeginRestore(out _)); + Assert.False(state.Snapshot.Buttons.CanRestore); + Assert.True(state.TryHighlight(0x50000002u)); + Assert.True(state.BeginEnter(out var ready)); + Assert.Equal(0x50000002u, ready.CharacterId); + + state.ReturnToSelection(); + Assert.True(state.TryHighlight(0x50000001u)); + Assert.True(state.TryBeginRestore(out _)); + time.Advance(RuntimeCharacterSelectionState.RestoreCorrelationTimeout); + Assert.True(state.SweepRestoreCorrelation()); + Assert.Equal( + RuntimeCharacterSelectionOperation.None, + state.Snapshot.Operation); + Assert.True(state.Snapshot.Buttons.CanRestore); } [Fact] From 10a712d66b8f53ab903056921092a08bdab0176b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:02:20 +0200 Subject: [PATCH 033/138] fix(launcher): close LA4 review findings --- .github/workflows/headless-portability.yml | 57 +++ .../Orchestration/ILauncherOrchestrator.cs | 7 +- .../Orchestration/LauncherExecutableSet.cs | 64 +++- .../Orchestration/LauncherOrchestrator.cs | 183 ++++++++-- .../Profiles/LauncherProfileStore.cs | 341 ++++++++++++++++-- src/AcDream.Launcher/AcDream.Launcher.csproj | 1 + src/AcDream.Launcher/MainWindow.axaml | 71 +++- src/AcDream.Launcher/MainWindow.axaml.cs | 96 +++++ src/AcDream.Launcher/Program.cs | 14 +- src/AcDream.Launcher/ViewModels/Commands.cs | 8 +- .../ViewModels/LauncherSessionRowViewModel.cs | 7 +- .../ViewModels/LauncherShellViewModel.cs | 14 +- .../ViewModels/LauncherWindowViewModel.cs | 169 ++++++++- .../LauncherExecutableSetTests.cs | 67 ++++ .../LauncherOrchestratorTests.cs | 231 +++++++++++- .../Profiles/LauncherProfileHardeningTests.cs | 171 +++++++++ .../Profiles/RosterMergeTests.cs | 60 +++ .../LauncherProjectBoundaryTests.cs | 90 +++++ .../LauncherWindowViewModelTests.cs | 114 +++++- 19 files changed, 1631 insertions(+), 134 deletions(-) create mode 100644 tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileHardeningTests.cs diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 6facc5ed..3fae2923 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -7,6 +7,7 @@ on: - "AcDream.slnx" - "src/AcDream.Platform/**" - "src/AcDream.Launcher.Core/**" + - "src/AcDream.Launcher/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -17,6 +18,7 @@ on: - "src/AcDream.UI.Abstractions/**" - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Launcher.Core.Tests/**" + - "tests/AcDream.Launcher.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -33,6 +35,7 @@ on: - "AcDream.slnx" - "src/AcDream.Platform/**" - "src/AcDream.Launcher.Core/**" + - "src/AcDream.Launcher/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -43,6 +46,7 @@ on: - "src/AcDream.UI.Abstractions/**" - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Launcher.Core.Tests/**" + - "tests/AcDream.Launcher.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -131,6 +135,59 @@ jobs: dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + portable-launcher: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Build and test the portable launcher + shell: pwsh + run: | + dotnet build src/AcDream.Launcher/AcDream.Launcher.csproj -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Publish the self-contained Linux launcher + if: runner.os == 'Linux' + shell: pwsh + run: | + dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj ` + -c Release ` + -r linux-x64 ` + -o artifacts/acdream-launcher-linux-x64 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify self-contained property and artifact execution + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + root=artifacts/acdream-launcher-linux-x64 + self_contained=$(dotnet msbuild \ + src/AcDream.Launcher/AcDream.Launcher.csproj \ + -nologo \ + -property:RuntimeIdentifier=linux-x64 \ + -getProperty:SelfContained | tr -d '\r\n ') + test "$self_contained" = true + test -x "$root/acdream-launcher" + test ! -f "$root/acdream-launcher.dll" + DOTNET_ROOT=/definitely-not-installed \ + DOTNET_ROOT_X64=/definitely-not-installed \ + DOTNET_MULTILEVEL_LOOKUP=0 \ + "$root/acdream-launcher" --verify-publish + linux-graphical: runs-on: ubuntu-latest diff --git a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs index c2003b95..e52c443f 100644 --- a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs @@ -18,6 +18,11 @@ public interface ILauncherOrchestrator : IDisposable LauncherCapability GetLaunchCapability(LaunchMode mode); + LauncherCapability GetAccountLaunchCapability( + string serverName, + string accountName, + LaunchMode mode); + LauncherCapability GetProbeCapability(string serverName, string accountName); void SetInstallRecord(LauncherInstallRecord? installRecord); @@ -67,7 +72,7 @@ public interface ILauncherOrchestrator : IDisposable Task LaunchAsync( string serverName, string accountName, - string characterName, + string? characterName, LaunchMode mode, CancellationToken cancellationToken = default); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index c3a4fa31..a02496ce 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -4,20 +4,59 @@ using AcDream.Launcher.Core.Profiles; namespace AcDream.Launcher.Core.Orchestration; /// -/// Host executable paths supplied by the current installation. LA10 will -/// resolve these from the versioned app/current pointer; LA4 keeps the -/// mapping injectable and host-agnostic. +/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will +/// replace the directory lookup with its versioned-current resolver; until +/// then a missing host disables the corresponding action instead of deferring +/// failure until process creation. /// -public sealed record LauncherExecutableSet( - string GraphicalHostPath, - string HeadlessHostPath, - string? WorkingDirectory = null) +public sealed class LauncherExecutableSet { + private readonly Func _fileExists; + + public LauncherExecutableSet( + string graphicalHostPath, + string headlessHostPath, + string? workingDirectory = null, + Func? fileExists = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath); + ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath); + GraphicalHostPath = graphicalHostPath; + HeadlessHostPath = headlessHostPath; + WorkingDirectory = workingDirectory; + _fileExists = fileExists ?? File.Exists; + } + + public string GraphicalHostPath { get; } + + public string HeadlessHostPath { get; } + + public string? WorkingDirectory { get; } + + public LauncherCapability GetAvailability(LaunchMode mode) + { + string path = mode == LaunchMode.Headless + ? HeadlessHostPath + : GraphicalHostPath; + if (_fileExists(path)) + { + return LauncherCapability.Available; + } + + string host = mode == LaunchMode.Headless + ? "headless host" + : "graphical client"; + return LauncherCapability.Unavailable( + $"The co-deployed {host} is missing at '{path}'. Reinstall or update " + + "the client before launching."); + } + public LauncherProcessSpec CreatePlaySpec( LaunchMode mode, string configFilePath) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + RequireAvailable(mode); return mode == LaunchMode.Headless ? new LauncherProcessSpec( @@ -33,6 +72,7 @@ public sealed record LauncherExecutableSet( public LauncherProcessSpec CreateProbeSpec(string configFilePath) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + RequireAvailable(LaunchMode.Headless); return new LauncherProcessSpec( HeadlessHostPath, ["--config", configFilePath], @@ -49,4 +89,14 @@ public sealed record LauncherExecutableSet( Path.Combine(fullDirectory, "acdream-headless" + executableSuffix), fullDirectory); } + + private void RequireAvailable(LaunchMode mode) + { + LauncherCapability capability = GetAvailability(mode); + if (!capability.IsAvailable) + { + throw new LauncherOperationException( + capability.Reason ?? "The selected launcher host is unavailable."); + } + } } diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index fe9a426f..57d344c9 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -99,6 +99,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator return platformCapability; } + LauncherCapability executableCapability = _executables.GetAvailability(mode); + if (!executableCapability.IsAvailable) + { + return executableCapability; + } + lock (_gate) { ThrowIfDisposed(); @@ -108,6 +114,33 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator } } + public LauncherCapability GetAccountLaunchCapability( + string serverName, + string accountName, + LaunchMode mode) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + ArgumentException.ThrowIfNullOrWhiteSpace(accountName); + + LauncherCapability capability = GetLaunchCapability(mode); + if (!capability.IsAvailable) + { + return capability; + } + + lock (_gate) + { + ThrowIfDisposed(); + _ = FindAccountLocked(serverName, accountName); + ManagedActivity? active = FindActiveActivityLocked(serverName, accountName); + return active is null + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + $"Stop the running {active.Kind.ToString().ToLowerInvariant()} " + + "for this account before starting another activity."); + } + } + public LauncherCapability GetProbeCapability(string serverName, string accountName) { ArgumentException.ThrowIfNullOrWhiteSpace(serverName); @@ -120,6 +153,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator return platformCapability; } + LauncherCapability executableCapability = + _executables.GetAvailability(LaunchMode.Headless); + if (!executableCapability.IsAvailable) + { + return executableCapability; + } + lock (_gate) { ThrowIfDisposed(); @@ -254,7 +294,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public Task LaunchAsync( string serverName, string accountName, - string characterName, + string? characterName, LaunchMode mode, CancellationToken cancellationToken = default) { @@ -268,12 +308,38 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator lock (_gate) { ThrowIfDisposed(); + if (FindActiveActivityLocked(serverName, accountName) is not null) + { + throw new LauncherOperationException( + "A session or character refresh is already running for this account."); + } + ServerProfile server = FindServerLocked(serverName); AccountProfile account = FindAccountLocked(serverName, accountName); - CharacterProfile character = FindCharacterLocked( - serverName, - accountName, - characterName); + CharacterProfile character; + if (string.IsNullOrWhiteSpace(characterName)) + { + if (mode != LaunchMode.GuiSelect) + { + throw new LauncherOperationException( + "Select a cached character for GUI or headless launch."); + } + + character = new CharacterProfile + { + Name = string.Empty, + LaunchMode = LaunchMode.GuiSelect, + Plugins = [], + LoginCommands = [], + }; + } + else + { + character = FindCharacterLocked( + serverName, + accountName, + characterName); + } LauncherInstallRecord install = _installRecord ?? throw new LauncherOperationException(FirstRunRequired); @@ -283,7 +349,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator LauncherActivityKind.Play, server.Name, account.Account, - character.Name, + string.IsNullOrWhiteSpace(characterName) ? null : character.Name, mode, "Preparing session configuration…"); _activities.Add(activity); @@ -620,9 +686,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator lock (_gate) { - request.Activity.State = LauncherActivityState.Cancelled; - request.Activity.Status = "Operation cancelled."; - request.Activity.Error = null; + if (!request.Activity.IsTerminal) + { + request.Activity.State = LauncherActivityState.Cancelled; + request.Activity.Status = "Operation cancelled."; + request.Activity.Error = null; + } } RaiseStateChanged(); @@ -638,9 +707,12 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator password); lock (_gate) { - request.Activity.State = LauncherActivityState.Failed; - request.Activity.Status = message; - request.Activity.Error = message; + if (!request.Activity.IsTerminal) + { + request.Activity.State = LauncherActivityState.Failed; + request.Activity.Status = message; + request.Activity.Error = message; + } } RaiseStateChanged(); @@ -681,15 +753,19 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator } break; case LauncherSessionState.Exited: - activity.ExitCode = activity.Supervisor?.ExitCode; - if (activity.State is not ( - LauncherActivityState.Failed - or LauncherActivityState.Cancelled)) + activity.ExitCode ??= activity.Supervisor?.ExitCode; + if (!activity.IsTerminal) { activity.State = LauncherActivityState.Exited; - activity.Status = activity.ExitCode is int code - ? $"Host process exited with code {code}." - : "Host process exited."; + activity.Status = activity.HostTerminalStatus + ?? (activity.ExitCode is int code + ? $"Host process exited with code {code}." + : "Host process exited."); + } + else if (activity.State == LauncherActivityState.Exited + && activity.HostTerminalStatus is not null) + { + activity.Status = activity.HostTerminalStatus; } break; } @@ -723,6 +799,27 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator return; } + if (activity.IsTerminal) + { + switch (statusEvent) + { + case ExitedStatusEvent exited: + activity.ExitCode ??= exited.Code; + activity.HostTerminalStatus ??= + $"Exited: {exited.Reason} (code {exited.Code})."; + if (activity.State == LauncherActivityState.Exited) + { + activity.Status = activity.HostTerminalStatus; + } + break; + case CharacterListStatusEvent roster: + ApplyRosterLocked(activity, roster, updateStatus: false); + break; + } + + return; + } + switch (statusEvent) { case StartedStatusEvent: @@ -762,7 +859,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator case ExitedStatusEvent exited: activity.State = LauncherActivityState.Exited; activity.ExitCode = exited.Code; - activity.Status = $"Exited: {exited.Reason} (code {exited.Code})."; + activity.HostTerminalStatus = + $"Exited: {exited.Reason} (code {exited.Code})."; + activity.Status = activity.HostTerminalStatus; break; case MalformedStatusEvent malformed: activity.Error = $"Malformed host status event: {malformed.Error}"; @@ -778,7 +877,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator private void ApplyRosterLocked( ManagedActivity activity, - CharacterListStatusEvent roster) + CharacterListStatusEvent roster, + bool updateStatus = true) { if (!string.Equals( roster.AccountName, @@ -792,19 +892,22 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator try { - _profileStore.MergeRoster( - activity.ServerName, - activity.AccountName, - roster.Characters - .Select(character => new CharacterRosterEntry( - character.Id, - character.Name, - character.SecondsGreyedOut)) - .ToArray()); - _profileStore.Save(); - activity.Status = roster.Characters.Count == 1 - ? "Character roster refreshed: 1 character." - : $"Character roster refreshed: {roster.Characters.Count} characters."; + _profileStore.ExecuteTransaction(() => + _profileStore.MergeRoster( + activity.ServerName, + activity.AccountName, + roster.Characters + .Select(character => new CharacterRosterEntry( + character.Id, + character.Name, + character.SecondsGreyedOut)) + .ToArray())); + if (updateStatus) + { + activity.Status = roster.Characters.Count == 1 + ? "Character roster refreshed: 1 character." + : $"Character roster refreshed: {roster.Characters.Count} characters."; + } } catch (Exception ex) { @@ -812,7 +915,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator "Could not save the refreshed character roster", ex, secret: null); - activity.Status = activity.Error; + if (updateStatus) + { + activity.Status = activity.Error; + } } } @@ -879,8 +985,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator lock (_gate) { ThrowIfDisposed(); - mutation(); - _profileStore.Save(); + _profileStore.ExecuteTransaction(mutation); } RaiseStateChanged(); @@ -1125,6 +1230,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public string? Error { get; set; } + public string? HostTerminalStatus { get; set; } + public ILauncherProcessSupervisor? Supervisor { get; set; } public EventHandler? SupervisorStateHandler { get; set; } @@ -1140,6 +1247,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator or LauncherActivityState.Failed or LauncherActivityState.Cancelled); + public bool IsTerminal => !IsActive; + public LauncherSessionSnapshot ToSnapshot() => new( SessionId, diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs index 01717400..f3a1a1b3 100644 --- a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -81,6 +81,8 @@ public sealed class LauncherProfileStore return false; } + EnsureExistingCredentialFilePermissions(); + LauncherProfileDocument? document; using (FileStream stream = File.OpenRead(FilePath)) { @@ -110,6 +112,7 @@ public sealed class LauncherProfileStore + $"expected {CurrentVersion}."); } + ValidateAndNormalizeDocument(document); Document = document; return true; } @@ -152,6 +155,13 @@ public sealed class LauncherProfileStore JsonSerializer.Serialize(stream, Document, SerializerOptions); } + if (OperatingSystem.IsLinux() + && File.GetUnixFileMode(tempPath) != OwnerOnlyFileMode) + { + throw new IOException( + "The launcher credential temp file could not be secured to mode 0600."); + } + File.Move(tempPath, FilePath, overwrite: true); } catch @@ -160,10 +170,6 @@ public sealed class LauncherProfileStore throw; } - if (OperatingSystem.IsLinux()) - { - File.SetUnixFileMode(FilePath, OwnerOnlyFileMode); - } } /// @@ -248,21 +254,21 @@ public sealed class LauncherProfileStore throw new LauncherProfileException( $"A server named '{newName}' already exists."); } - - server.Name = newName; } if (newHost is not null) { ArgumentException.ThrowIfNullOrWhiteSpace(newHost); - server.Host = newHost; } if (newPort is not null) { RequireValidPort(newPort.Value); - server.Port = newPort.Value; } + + server.Name = newName ?? server.Name; + server.Host = newHost ?? server.Host; + server.Port = newPort ?? server.Port; } public void RemoveServer(string name) @@ -308,10 +314,10 @@ public sealed class LauncherProfileStore throw new LauncherProfileException( $"Account '{newAccount}' already exists on server '{serverName}'."); } - - profile.Account = newAccount; } + profile.Account = newAccount ?? profile.Account; + if (newPassword is not null) { profile.Password = newPassword; @@ -345,6 +351,9 @@ public sealed class LauncherProfileStore IReadOnlyList? loginCommands = null) { ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + RequireValidLaunchMode(launchMode); + ValidateStringList(plugins, "plugin", requireUnique: true); + ValidateStringList(loginCommands, "login command", requireUnique: false); ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); @@ -393,6 +402,8 @@ public sealed class LauncherProfileStore AccountProfile profile = FindAccountOrThrow(server, account); CharacterProfile character = FindCharacterOrThrow(profile, characterName); + string? normalizedId = null; + if (newName is not null) { ArgumentException.ThrowIfNullOrWhiteSpace(newName); @@ -402,13 +413,11 @@ public sealed class LauncherProfileStore throw new LauncherProfileException( $"Character '{newName}' already exists on account '{account}'."); } - - character.Name = newName; } if (newId is not null) { - string? normalizedId = NormalizeCharacterId(newId); + normalizedId = NormalizeCharacterId(newId); if (normalizedId is not null && profile.Characters.Any(candidate => !ReferenceEquals(candidate, character) @@ -417,7 +426,19 @@ public sealed class LauncherProfileStore throw new LauncherProfileException( $"Character id '{normalizedId}' already exists on account '{account}'."); } + } + if (launchMode is not null) + { + RequireValidLaunchMode(launchMode.Value); + } + + ValidateStringList(plugins, "plugin", requireUnique: true); + ValidateStringList(loginCommands, "login command", requireUnique: false); + + character.Name = newName ?? character.Name; + if (newId is not null) + { character.Id = normalizedId; } @@ -437,6 +458,57 @@ public sealed class LauncherProfileStore } } + private void EnsureExistingCredentialFilePermissions() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + try + { + UnixFileMode mode = File.GetUnixFileMode(FilePath); + if (mode != OwnerOnlyFileMode) + { + File.SetUnixFileMode(FilePath, OwnerOnlyFileMode); + mode = File.GetUnixFileMode(FilePath); + } + + if (mode != OwnerOnlyFileMode) + { + throw new IOException($"Mode remained {mode} after normalization."); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new LauncherProfileException( + $"'{FilePath}' could not be secured to owner-only mode 0600.", + ex); + } + } + + /// + /// Applies one profile mutation and its atomic file replacement as a + /// single in-memory/on-disk transaction. Any validation or I/O failure + /// restores the exact pre-mutation document, including credentials. + /// + public void ExecuteTransaction(Action mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + LauncherProfileDocument before = CloneDocument(Document); + try + { + mutation(); + ValidateAndNormalizeDocument(Document); + Save(); + } + catch + { + Document = before; + throw; + } + } + public void RemoveCharacter( string serverName, string account, @@ -472,38 +544,49 @@ public sealed class LauncherProfileStore ServerProfile server = FindServerOrThrow(serverName); AccountProfile profile = FindAccountOrThrow(server, account); + var rosterIds = new HashSet(); + var rosterNames = new HashSet(StringComparer.Ordinal); + foreach (CharacterRosterEntry entry in roster) + { + if (entry.Id == 0) + { + throw new LauncherProfileException("A roster character id cannot be zero."); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(entry.Name); + if (!rosterIds.Add(entry.Id) || !rosterNames.Add(entry.Name)) + { + throw new LauncherProfileException( + "The reported character roster contains a duplicate id or name."); + } + } + foreach (CharacterRosterEntry entry in roster) { string idText = CharacterIdFormat.ToHexString(entry.Id); - // Normalize BOTH sides through TryParse/ToHexString rather - // than a raw string compare (Campaign LA plan §LA3 review - // finding F10): a stored id that round-trips to the same - // uint (different case, or — before this fix — no "0x" - // prefix) must match even though its text isn't byte- - // identical to the canonical form this method itself always - // writes. - CharacterProfile? existing = profile.Characters.Find( - character => CharacterIdFormat.TryParse(character.Id, out uint existingId) - && existingId == entry.Id); - - // Defensive fallback for a row whose id is missing OR - // unparseable (e.g. a hand-edited id with no "0x" prefix, - // which TryParse now rejects outright) — match by name - // instead so a later merge self-heals the id into the - // canonical form rather than creating a permanent duplicate - // row. - existing ??= profile.Characters.Find( - character => !CharacterIdFormat.TryParse(character.Id, out _) - && string.Equals( - character.Name, - entry.Name, - StringComparison.Ordinal)); + CharacterProfile[] matches = profile.Characters + .Where(character => + (CharacterIdFormat.TryParse(character.Id, out uint existingId) + && existingId == entry.Id) + || string.Equals(character.Name, entry.Name, StringComparison.Ordinal)) + .ToArray(); + CharacterProfile? existing = matches.FirstOrDefault(character => + CharacterIdFormat.TryParse(character.Id, out uint existingId) + && existingId == entry.Id) + ?? matches.FirstOrDefault(); if (existing is not null) { existing.Id = idText; existing.Name = entry.Name; + foreach (CharacterProfile duplicate in matches) + { + if (!ReferenceEquals(duplicate, existing)) + { + profile.Characters.Remove(duplicate); + } + } continue; } @@ -583,6 +666,192 @@ public sealed class LauncherProfileStore && CharacterIdFormat.TryParse(right, out uint rightId) && leftId == rightId; + private static LauncherProfileDocument CloneDocument( + LauncherProfileDocument source) => + new() + { + Version = source.Version, + Servers = source.Servers.Select(server => new ServerProfile + { + Name = server.Name, + Host = server.Host, + Port = server.Port, + Accounts = server.Accounts.Select(account => new AccountProfile + { + Account = account.Account, + Password = account.Password, + Characters = account.Characters.Select(character => new CharacterProfile + { + Name = character.Name, + Id = character.Id, + LaunchMode = character.LaunchMode, + Plugins = [.. character.Plugins], + LoginCommands = [.. character.LoginCommands], + }).ToList(), + }).ToList(), + }).ToList(), + }; + + private static void ValidateAndNormalizeDocument(LauncherProfileDocument document) + { + if (document.Servers is null) + { + throw new LauncherProfileException("The servers collection cannot be null."); + } + + var serverNames = new HashSet(StringComparer.Ordinal); + var normalizedIds = new List<(CharacterProfile Character, uint Id)>(); + foreach (ServerProfile? server in document.Servers) + { + if (server is null) + { + throw new LauncherProfileException("A server entry cannot be null."); + } + + RequireLoadedText(server.Name, "server name"); + RequireLoadedText(server.Host, $"host for server '{server.Name}'"); + RequireValidPort(server.Port); + if (!serverNames.Add(server.Name)) + { + throw new LauncherProfileException( + $"A server named '{server.Name}' appears more than once."); + } + + if (server.Accounts is null) + { + throw new LauncherProfileException( + $"The accounts collection for server '{server.Name}' cannot be null."); + } + + var accountNames = new HashSet(StringComparer.Ordinal); + foreach (AccountProfile? account in server.Accounts) + { + if (account is null) + { + throw new LauncherProfileException( + $"A null account appears under server '{server.Name}'."); + } + + RequireLoadedText(account.Account, "account name"); + if (account.Password is null) + { + throw new LauncherProfileException( + $"Password for account '{account.Account}' cannot be null."); + } + + if (!accountNames.Add(account.Account)) + { + throw new LauncherProfileException( + $"Account '{account.Account}' appears more than once on server '{server.Name}'."); + } + + if (account.Characters is null) + { + throw new LauncherProfileException( + $"The characters collection for account '{account.Account}' cannot be null."); + } + + var characterNames = new HashSet(StringComparer.Ordinal); + var characterIds = new HashSet(); + foreach (CharacterProfile? character in account.Characters) + { + if (character is null) + { + throw new LauncherProfileException( + $"A null character appears under account '{account.Account}'."); + } + + RequireLoadedText(character.Name, "character name"); + if (!characterNames.Add(character.Name)) + { + throw new LauncherProfileException( + $"Character '{character.Name}' appears more than once on account '{account.Account}'."); + } + + RequireValidLaunchMode(character.LaunchMode); + if (character.Id is not null) + { + if (!CharacterIdFormat.TryParse(character.Id, out uint id) || id == 0) + { + throw new LauncherProfileException( + $"Character '{character.Name}' has an invalid id '{character.Id}'."); + } + + if (!characterIds.Add(id)) + { + throw new LauncherProfileException( + $"Character id '{character.Id}' appears more than once on account '{account.Account}'."); + } + + normalizedIds.Add((character, id)); + } + + if (character.Plugins is null || character.LoginCommands is null) + { + throw new LauncherProfileException( + $"Character '{character.Name}' has a null settings collection."); + } + + ValidateStringList(character.Plugins, "plugin", requireUnique: true); + ValidateStringList( + character.LoginCommands, + "login command", + requireUnique: false); + } + } + } + + foreach ((CharacterProfile character, uint id) in normalizedIds) + { + character.Id = CharacterIdFormat.ToHexString(id); + } + } + + private static void ValidateStringList( + IReadOnlyList? values, + string valueName, + bool requireUnique) + { + if (values is null) + { + return; + } + + HashSet? seen = requireUnique + ? new HashSet(StringComparer.Ordinal) + : null; + foreach (string? value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherProfileException( + $"A {valueName} cannot be null or whitespace."); + } + + if (seen is not null && !seen.Add(value)) + { + throw new LauncherProfileException( + $"The {valueName} '{value}' appears more than once."); + } + } + } + + private static void RequireLoadedText(string? value, string field) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherProfileException($"The {field} cannot be null or whitespace."); + } + } + + private static void RequireValidLaunchMode(LaunchMode mode) + { + if (!Enum.IsDefined(mode)) + { + throw new LauncherProfileException($"Launch mode '{mode}' is not supported."); + } + } + private static void RequireValidPort(int port) { if (port is < 1 or > 65535) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 33b07cd5..a4c20260 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -10,6 +10,7 @@ true true true + true diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index cc63bb44..f85d1e8a 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -85,7 +85,8 @@ public sealed record LoadedPlugin( PluginManifest Manifest, IAcDreamPlugin? Plugin, AssemblyLoadContext? LoadContext, - Exception? Error) + Exception? Error, + WeakReference? ReleasedLoadContext = null) { public bool Success => Plugin is not null && Error is null; } diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index 85581642..54042a51 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -48,13 +48,15 @@ public static class PluginLoader if (pluginType is null) { + var released = new WeakReference(alc); alc.Unload(); return new LoadedPlugin( manifest, Plugin: null, LoadContext: null, Error: new InvalidOperationException( - $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); + $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"), + ReleasedLoadContext: released); } instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; @@ -68,9 +70,15 @@ public static class PluginLoader // as an Enable failure before releasing the collectible context. try { instance?.Disable(); } catch { } + WeakReference? released = alc is null ? null : new WeakReference(alc); try { alc?.Unload(); } catch { } - return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex); + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: null, + Error: ex, + ReleasedLoadContext: released); } } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index 5c939fd7..dfbbb3ef 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -27,7 +27,8 @@ public sealed class PluginSession : IDisposable { private readonly IPluginHost _host; private readonly Action? _report; - private readonly List _loaded = []; + private readonly List _loaded = []; + private readonly List _releasedContexts = []; private bool _started; private bool _disposed; @@ -42,7 +43,7 @@ public sealed class PluginSession : IDisposable public int LoadedCount => _loaded.Count; public IReadOnlyList LoadedPluginIds => - _loaded.Select(static plugin => plugin.Manifest.Id).ToArray(); + _loaded.Select(static active => active.Loaded.Manifest.Id).ToArray(); /// /// Discovers and starts the configured set exactly once. A @@ -143,9 +144,11 @@ public sealed class PluginSession : IDisposable /// owned by this session. The returned weak references do not delay unload. /// public IReadOnlyList CaptureLoadContextWeakReferences() => - _loaded - .Select(static plugin => new WeakReference(plugin.LoadContext!)) - .ToArray(); + [ + .. _releasedContexts, + .. _loaded.Select(static active => + new WeakReference(active.Loaded.LoadContext!)), + ]; public void Dispose() { @@ -155,7 +158,8 @@ public sealed class PluginSession : IDisposable for (int index = _loaded.Count - 1; index >= 0; index--) { - LoadedPlugin loaded = _loaded[index]; + ActivePlugin active = _loaded[index]; + LoadedPlugin loaded = active.Loaded; try { loaded.Plugin!.Disable(); @@ -169,6 +173,11 @@ public sealed class PluginSession : IDisposable error); } + // Host-owned registrations are released even when Disable throws. + // This must precede ALC unload so no UI binding or event delegate + // can keep the plugin assembly reachable. + active.Scope.Dispose(); + try { loaded.LoadContext!.Unload(); @@ -198,12 +207,16 @@ public sealed class PluginSession : IDisposable { foreach (PluginDiscoveryResult candidate in available) { + var scope = new ScopedPluginHost(_host); LoadedPlugin loaded = PluginLoader.Load( candidate.PluginDirectory, candidate.Manifest!, - _host); + scope); if (!loaded.Success) { + scope.Dispose(); + if (loaded.ReleasedLoadContext is { } released) + _releasedContexts.Add(released); AddError( errors, id, @@ -215,7 +228,7 @@ public sealed class PluginSession : IDisposable try { loaded.Plugin!.Enable(); - _loaded.Add(loaded); + _loaded.Add(new ActivePlugin(loaded, scope)); SafeLog( static (log, message, _) => log.Info(message), $"plugin loaded: {loaded.Manifest.Id} " @@ -229,7 +242,7 @@ public sealed class PluginSession : IDisposable catch (Exception error) { AddError(errors, id, error); - ReleaseFailedEnable(loaded); + ReleaseFailedEnable(loaded, scope); } } } @@ -257,7 +270,9 @@ public sealed class PluginSession : IDisposable null); } - private void ReleaseFailedEnable(LoadedPlugin loaded) + private void ReleaseFailedEnable( + LoadedPlugin loaded, + ScopedPluginHost scope) { try { @@ -272,6 +287,9 @@ public sealed class PluginSession : IDisposable error); } + scope.Dispose(); + + _releasedContexts.Add(new WeakReference(loaded.LoadContext!)); try { loaded.LoadContext!.Unload(); @@ -358,4 +376,8 @@ public sealed class PluginSession : IDisposable or ArgumentException or NotSupportedException or System.Security.SecurityException; + + private sealed record ActivePlugin( + LoadedPlugin Loaded, + ScopedPluginHost Scope); } diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs new file mode 100644 index 00000000..61786b22 --- /dev/null +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -0,0 +1,171 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// +/// Per-plugin host view that owns every registration made through the public +/// event/UI surfaces. Disposal is the host's rollback boundary: it removes +/// registrations even when plugin Initialize/Enable/Disable code throws. +/// +internal sealed class ScopedPluginHost : IPluginHost, IDisposable +{ + private readonly IPluginHost _inner; + private readonly ScopedEvents _events; + private readonly ScopedUiRegistry _ui; + private bool _disposed; + + internal ScopedPluginHost(IPluginHost inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _events = new ScopedEvents(inner.Events); + _ui = new ScopedUiRegistry(inner.Ui); + } + + public bool HasUi => _inner.HasUi; + public IPluginLogger Log => _inner.Log; + public IGameState State => _inner.State; + public IEvents Events => _events; + public ISelectionService Selection => _inner.Selection; + public IUiRegistry Ui => _ui; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _events.Dispose(); + _ui.Dispose(); + } + + private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable + { + private readonly object _gate = new(); + private readonly List> _registrations = []; + private bool _disposed; + + public event Action EntitySpawned + { + add + { + ArgumentNullException.ThrowIfNull(value); + try + { + inner.EntitySpawned += value; + } + catch + { + // A custom event source may mutate before its add accessor + // faults. Best-effort removal keeps the scope transactional. + try { inner.EntitySpawned -= value; } + catch { } + throw; + } + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(value); + return; + } + } + + // Disposal may race the host subscription call. In that case + // the disposal snapshot could not see this registration, so + // the attaching thread must roll it back before returning. + try { inner.EntitySpawned -= value; } + catch { } + throw new ObjectDisposedException(nameof(ScopedEvents)); + } + remove + { + if (value is null) + return; + inner.EntitySpawned -= value; + lock (_gate) + RemoveLast(value); + } + } + + public void Dispose() + { + Action[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { inner.EntitySpawned -= registrations[index]; } + catch { } + } + } + + private void RemoveLast(Action handler) + { + for (int index = _registrations.Count - 1; index >= 0; index--) + { + if (_registrations[index] != handler) + continue; + _registrations.RemoveAt(index); + return; + } + } + } + + private sealed class ScopedUiRegistry : IUiRegistry, IDisposable + { + private readonly IScopedUiRegistry _inner; + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + internal ScopedUiRegistry(IUiRegistry inner) + { + _inner = inner as IScopedUiRegistry + ?? throw new InvalidOperationException( + "Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back."); + } + + public void AddMarkupPanel(string markupPath, object binding) + { + IDisposable registration = _inner.RegisterMarkupPanel( + markupPath, + binding); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return; + } + } + + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedUiRegistry)); + } + + public void Dispose() + { + IDisposable[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { registrations[index].Dispose(); } + catch { } + } + } + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index eb0ce2a5..9e025f61 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -301,7 +301,7 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); - pluginSession = HeadlessPluginSession.Start( + pluginSession = HeadlessPluginSession.Create( runtime, diagnostics, statusWriter, @@ -488,6 +488,7 @@ internal sealed class HeadlessSessionHost : IDisposable // Campaign LA slice LA1: "started" = session host start — the // earliest point this session actually attempts to connect. _statusWriter.Started(_descriptor.Id); + _pluginSession.Start(); RuntimeSessionStartResult result = Commands.Session.Start(Runtime.Generation); _startOutcome = result.Status; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index f68c4bc5..5eda1706 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -19,9 +19,23 @@ internal sealed class HeadlessPluginHost private readonly GameRuntime _runtime; private readonly IDisposable _eventSubscription; private readonly object _eventGate = new(); - private Action? _entitySpawned; + private readonly List _subscriptions = []; + private Subscription[] _liveSnapshot = []; private bool _disposed; + private readonly record struct ReplayEntity( + RuntimeEntityIdentity Identity, + WorldEntitySnapshot Snapshot); + + private sealed class Subscription(Action handler) + { + internal Action Handler { get; } = handler; + internal Queue Pending { get; } = new(); + internal HashSet Delivered { get; } = []; + internal bool Replaying { get; set; } = true; + internal bool Active { get; set; } = true; + } + internal HeadlessPluginHost( GameRuntime runtime, IPluginLogger logger) @@ -38,6 +52,10 @@ internal sealed class HeadlessPluginHost public ISelectionService Selection => _runtime.ActionOwner.Selection; public IUiRegistry Ui => NoOpUiRegistry.Instance; + /// Test-only barrier after the borrowed replay snapshot is + /// captured and before delivery starts. + internal Action? ReplayCapturedForTest { get; set; } + /// /// Immutable point-in-time values produced directly from Runtime on each /// read. The caller owns the returned snapshot list; this host retains no @@ -50,7 +68,7 @@ internal sealed class HeadlessPluginHost ObjectDisposedException.ThrowIf(_disposed, this); var visitor = new SnapshotVisitor(_runtime); _runtime.Entities.Visit(visitor); - return visitor.Snapshots; + return visitor.Items.Select(static item => item.Snapshot).ToArray(); } } @@ -60,20 +78,81 @@ internal sealed class HeadlessPluginHost { ArgumentNullException.ThrowIfNull(value); ObjectDisposedException.ThrowIf(_disposed, this); + var subscription = new Subscription(value); lock (_eventGate) - _entitySpawned += value; + { + ObjectDisposedException.ThrowIf(_disposed, this); + _subscriptions.Add(subscription); + } - // Match the graphical WorldEvents contract: a late subscriber - // immediately observes the canonical world that exists now. - foreach (WorldEntitySnapshot snapshot in Entities) - Invoke(value, snapshot); + // Arm the pending queue before borrowing Runtime's snapshot. This + // avoids a host-lock/Runtime-lock inversion while the identity + // dedup below collapses any registration present in both views. + var visitor = new SnapshotVisitor(_runtime); + _runtime.Entities.Visit(visitor); + ReplayEntity[] replay = visitor.Items.ToArray(); + + ReplayCapturedForTest?.Invoke(); + foreach (ReplayEntity item in replay) + { + lock (_eventGate) + { + if (!subscription.Active) + return; + if (!_runtime.Entities.TryGet( + item.Identity.ServerGuid, + out RuntimeEntitySnapshot current) + || current.Identity != item.Identity + || !subscription.Delivered.Add(item.Identity)) + { + continue; + } + } + + Invoke(subscription.Handler, item.Snapshot); + } + + while (true) + { + ReplayEntity pending; + lock (_eventGate) + { + if (!subscription.Active) + return; + if (!subscription.Pending.TryDequeue(out pending)) + { + subscription.Replaying = false; + subscription.Delivered.Clear(); + RebuildLiveSnapshotLocked(); + return; + } + if (!subscription.Delivered.Add(pending.Identity)) + continue; + } + + Invoke(subscription.Handler, pending.Snapshot); + } } remove { if (value is null) return; lock (_eventGate) - _entitySpawned -= value; + { + for (int index = _subscriptions.Count - 1; index >= 0; index--) + { + Subscription subscription = _subscriptions[index]; + if (subscription.Handler != value) + continue; + subscription.Active = false; + subscription.Pending.Clear(); + subscription.Delivered.Clear(); + _subscriptions.RemoveAt(index); + if (!subscription.Replaying) + RebuildLiveSnapshotLocked(); + break; + } + } } } @@ -81,28 +160,45 @@ internal sealed class HeadlessPluginHost { if (_disposed) return; - _eventSubscription.Dispose(); - _disposed = true; lock (_eventGate) - _entitySpawned = null; + { + _disposed = true; + foreach (Subscription subscription in _subscriptions) + { + subscription.Active = false; + subscription.Pending.Clear(); + subscription.Delivered.Clear(); + } + _subscriptions.Clear(); + _liveSnapshot = []; + } + _eventSubscription.Dispose(); } public void OnEntity(in RuntimeEntityDelta delta) { - if (_disposed || delta.Change != RuntimeEntityChange.Registered) + if (delta.Change != RuntimeEntityChange.Registered) return; - Action? handlers; + Subscription[] toNotify; + var pending = new ReplayEntity( + delta.Entity.Identity, + Convert(_runtime, delta.Entity)); lock (_eventGate) - handlers = _entitySpawned; - if (handlers is null) + { + if (_disposed) + return; + foreach (Subscription subscription in _subscriptions) + { + if (subscription.Active && subscription.Replaying) + subscription.Pending.Enqueue(pending); + } + toNotify = _liveSnapshot; + } + if (toNotify.Length == 0) return; - WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity); - foreach (Action handler - in handlers.GetInvocationList().Cast>()) - { - Invoke(handler, snapshot); - } + foreach (Subscription subscription in toNotify) + Invoke(subscription.Handler, pending.Snapshot); } public void OnLifecycle(in RuntimeLifecycleDelta delta) { } @@ -141,10 +237,20 @@ internal sealed class HeadlessPluginHost private sealed class SnapshotVisitor(GameRuntime runtime) : IRuntimeEntityVisitor { - internal List Snapshots { get; } = + internal List Items { get; } = new(runtime.Entities.Count); public void Visit(in RuntimeEntitySnapshot entity) => - Snapshots.Add(Convert(runtime, entity)); + Items.Add(new ReplayEntity( + entity.Identity, + Convert(runtime, entity))); + } + + private void RebuildLiveSnapshotLocked() + { + _liveSnapshot = _subscriptions + .Where(static subscription => + subscription.Active && !subscription.Replaying) + .ToArray(); } } diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index cff1226a..d199752b 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -14,24 +14,31 @@ internal sealed class HeadlessPluginSession : IDisposable { private readonly HeadlessPluginHost _host; private readonly PluginSession _plugins; + private readonly string[] _roots; + private readonly IReadOnlyList? _allowList; private int _disposeStage; + private bool _started; private bool _disposed; private HeadlessPluginSession( HeadlessPluginHost host, - PluginSession plugins) + PluginSession plugins, + string[] roots, + IReadOnlyList? allowList) { _host = host; _plugins = plugins; + _roots = roots; + _allowList = allowList; } internal int LoadedCount => _plugins.LoadedCount; - internal IPluginHost Host => _host; + internal HeadlessPluginHost Host => _host; internal IReadOnlyList CaptureLoadContextWeakReferences() => _plugins.CaptureLoadContextWeakReferences(); - internal static HeadlessPluginSession Start( + internal static HeadlessPluginSession Create( GameRuntime runtime, HeadlessDiagnosticWriter diagnostics, SessionStatusWriter statusWriter, @@ -54,17 +61,21 @@ internal sealed class HeadlessPluginSession : IDisposable var plugins = new PluginSession( host, status => Report(statusWriter, sessionId, status)); - try - { - plugins.Start(roots, allowList); - return new HeadlessPluginSession(host, plugins); - } - catch - { - plugins.Dispose(); - host.Dispose(); - throw; - } + return new HeadlessPluginSession( + host, + plugins, + roots.ToArray(), + allowList); + } + + internal void Start() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_started) + throw new InvalidOperationException( + "The headless plugin session has already started."); + _started = true; + _plugins.Start(_roots, _allowList); } public void Dispose() diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index 1b4349d2..e8452714 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -74,7 +74,9 @@ public static class SessionConfigComposer Character = selector, Policy = policy, Credential = new SessionCredentialDescriptor(), - Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null, + // LA5 distinguishes an omitted allow-list (load all, preserving + // the developer flow) from an explicit empty list (load none). + Plugins = [.. character.Plugins], LoginCommands = character.LoginCommands.Count > 0 ? [.. character.LoginCommands] : null, @@ -107,9 +109,9 @@ public static class SessionConfigComposer /// §LA3 review finding F2): the session carries mode: "probe", /// no character selector, and no policy — the host /// reports the account's character roster over the status stream and - /// exits without entering the world. plugins/loginCommands - /// don't apply to a probe and are always omitted, exactly like an - /// empty configured set on a normal session. + /// exits without entering the world. Probes carry an explicit empty + /// plugins allow-list so a plugin installed on the machine cannot + /// run merely because the probe has no character-level plugin settings. /// public static ComposedSessionConfig ComposeProbe( ServerProfile server, @@ -139,7 +141,7 @@ public static class SessionConfigComposer Character = null, Policy = null, Credential = new SessionCredentialDescriptor(), - Plugins = null, + Plugins = [], LoginCommands = null, LoginCommandDelayMs = null, StatusFile = statusFilePath, diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs index 0550f170..ca587dcf 100644 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -15,12 +15,24 @@ public interface IUiRegistry void AddMarkupPanel(string markupPath, object binding); } +/// +/// Host-infrastructure extension used to give each plugin a removable UI +/// registration lifetime. Plugins continue to call +/// ; the shared plugin host wraps that +/// call and owns the returned token so failed initialization, failed enable, +/// and shutdown can roll the registration back without trusting plugin code. +/// +public interface IScopedUiRegistry : IUiRegistry +{ + IDisposable RegisterMarkupPanel(string markupPath, object binding); +} + /// /// BCL-only UI sink for no-window plugin hosts. It intentionally retains /// neither markup paths nor binding objects, so a UI registration cannot keep /// a plugin assembly alive after its collectible load context is unloaded. /// -public sealed class NoOpUiRegistry : IUiRegistry +public sealed class NoOpUiRegistry : IScopedUiRegistry { public static NoOpUiRegistry Instance { get; } = new(); @@ -31,4 +43,16 @@ public sealed class NoOpUiRegistry : IUiRegistry public void AddMarkupPanel(string markupPath, object binding) { } + + public IDisposable RegisterMarkupPanel(string markupPath, object binding) => + NoOpRegistration.Instance; + + private sealed class NoOpRegistration : IDisposable + { + internal static NoOpRegistration Instance { get; } = new(); + + public void Dispose() + { + } + } } diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs index c37ce9fe..5e83aa49 100644 --- a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs @@ -56,6 +56,18 @@ public sealed class SessionConfigurationSharedFixtureTests StringComparison.Ordinal); } + [Fact] + public void AppReaderPreservesLauncherExplicitEmptyPluginAllowList() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.ComposeEmptyPlugins()); + + (_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path); + + Assert.NotNull(session.Plugins); + Assert.Empty(session.Plugins); + } + [Fact] public void AppReaderAcceptsTheProductionShapedSharedFixture() { diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs index 6e22e17f..23e84896 100644 --- a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs @@ -1,4 +1,5 @@ using AcDream.App.Plugins; +using AcDream.App.UI; namespace AcDream.App.Tests.Plugins; @@ -18,4 +19,26 @@ public class BufferedUiRegistryTests Assert.Empty(reg.Drain()); // consumed } + + [Fact] + public void ScopedRegistrationTokenRemovesAnAlreadyMountedElement() + { + var registry = new BufferedUiRegistry(); + IDisposable registration = registry.RegisterMarkupPanel( + "plugin.xml", + new object()); + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + var root = new UiRoot(); + var element = new UiPanel(); + root.AddChild(element); + registry.CompleteMount(pending, root, element); + + Assert.Contains(element, root.Children); + Assert.Equal(1, registry.RegistrationCount); + + registration.Dispose(); + + Assert.DoesNotContain(element, root.Children); + Assert.Equal(0, registry.RegistrationCount); + } } diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs index b77e2cb6..8f4a2e10 100644 --- a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -1,17 +1,20 @@ using System.Text.Json; using System.Runtime.CompilerServices; +using AcDream.App.Configuration; using AcDream.App.Plugins; using AcDream.Core.Plugins; using AcDream.Core.Selection; using AcDream.Platform; using AcDream.Plugin.Abstractions; using AcDream.Runtime.Session; +using AcDream.Tests.Fixtures.CampaignLa; namespace AcDream.App.Tests.Plugins; public sealed class GraphicalPluginSessionTests { private const string FixtureId = "acdream.test.host-fixture"; + private const string ThrowingId = "acdream.test.throwing-fixture"; [Fact] public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() @@ -27,12 +30,13 @@ public sealed class GraphicalPluginSessionTests var ui = new BufferedUiRegistry(); var host = new AppPluginHost(logger, state, events, selection, ui); - using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( paths, [FixtureId.ToUpperInvariant(), "acdream.test.missing"], "gui-session", host, new SessionStatusWriter(statusPath)); + plugins.Start(); Assert.Equal(1, plugins.LoadedCount); Assert.True(host.HasUi); @@ -42,19 +46,20 @@ public sealed class GraphicalPluginSessionTests message => message.Contains("fixture-enabled:hasUi=True", StringComparison.Ordinal)); JsonElement[] statuses = ReadStatuses(statusPath); - Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); - Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); + Assert.Equal(["started", "pluginLoaded", "pluginFailed"], EventNames(statuses)); + Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString()); Assert.Equal( "acdream.test.missing", - statuses[1].GetProperty("plugin").GetString()); + statuses[2].GetProperty("plugin").GetString()); Assert.Contains( "not found", - statuses[1].GetProperty("error").GetString(), + statuses[2].GetProperty("error").GetString(), StringComparison.OrdinalIgnoreCase); WeakReference context = Assert.Single( plugins.CaptureLoadContextWeakReferences()); plugins.Dispose(); + Assert.Equal(0, ui.RegistrationCount); Collect(context); Assert.False(context.IsAlive); } @@ -66,6 +71,12 @@ public sealed class GraphicalPluginSessionTests ApplicationPathSet paths = Paths(temporary.Path); InstallFixture(paths.PluginsDirectory, FixtureId); string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + string configPath = Path.Combine(temporary.Path, "session.json"); + File.WriteAllText( + configPath, + LauncherCoreSessionConfigFixture.ComposeEmptyPlugins()); + (_, SessionDescriptor descriptor) = + SessionConfigurationLoader.Load(configPath); var ui = new BufferedUiRegistry(); var host = new AppPluginHost( new CapturingLogger(), @@ -74,16 +85,70 @@ public sealed class GraphicalPluginSessionTests new SelectionState(), ui); - using GraphicalPluginSession plugins = GraphicalPluginSession.Start( + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( paths, - [], + descriptor.Plugins, "gui-session", host, new SessionStatusWriter(statusPath)); + plugins.Start(); + + Assert.Equal(0, plugins.LoadedCount); + Assert.NotNull(descriptor.Plugins); + Assert.Empty(descriptor.Plugins); + Assert.Empty(ui.Drain()); + Assert.Equal(["started"], EventNames(ReadStatuses(statusPath))); + } + + [Fact] + public void ThrowAfterRegistrationRollsBackUiAndEventsAndCollectsContext() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + string pluginDirectory = InstallFixture( + paths.PluginsDirectory, + ThrowingId, + "throwing-fixture"); + File.WriteAllText( + Path.Combine(pluginDirectory, "throw-after-register"), + string.Empty); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var events = new WorldEvents(); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost( + new CapturingLogger(), + new WorldGameState(), + events, + new SelectionState(), + ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( + paths, + [ThrowingId], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + plugins.Start(); Assert.Equal(0, plugins.LoadedCount); Assert.Empty(ui.Drain()); - Assert.False(File.Exists(statusPath)); + Assert.Equal(0, ui.RegistrationCount); + events.FireEntitySpawned(new WorldEntitySnapshot( + 1u, + 2u, + default, + System.Numerics.Quaternion.Identity)); + Assert.False(File.Exists( + Path.Combine(pluginDirectory, "unexpected-callback"))); + Assert.Equal( + ["started", "pluginFailed"], + EventNames(ReadStatuses(statusPath))); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + plugins.Dispose(); + Collect(context); + Assert.False(context.IsAlive); } private static ApplicationPathSet Paths(string root) => new( @@ -114,11 +179,14 @@ public sealed class GraphicalPluginSessionTests private static string[] EventNames(IEnumerable events) => events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); - private static void InstallFixture(string root, string id) + private static string InstallFixture( + string root, + string id, + string directoryName = "host-fixture") { string source = FixtureAssemblyPath(); Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); - string pluginDirectory = Path.Combine(root, "host-fixture"); + string pluginDirectory = Path.Combine(root, directoryName); Directory.CreateDirectory(pluginDirectory); string fileName = Path.GetFileName(source); File.Copy(source, Path.Combine(pluginDirectory, fileName)); @@ -132,6 +200,7 @@ public sealed class GraphicalPluginSessionTests entryDll = fileName, apiVersion = 1, })); + return pluginDirectory; } private static string FixtureAssemblyPath() @@ -195,8 +264,22 @@ public sealed class GraphicalPluginSessionTests public void Dispose() { - if (Directory.Exists(Path)) - Directory.Delete(Path, recursive: true); + for (int attempt = 0; Directory.Exists(Path); attempt++) + { + try + { + Directory.Delete(Path, recursive: true); + return; + } + catch (Exception error) + when (error is IOException or UnauthorizedAccessException + && attempt < 9) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + Thread.Sleep(10); + } + } } } } diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 017762cd..f6ce4c9c 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -447,6 +447,11 @@ public sealed class GameWindowSlice8BoundaryTests public void Shutdown_PreservesDependencyStagesAndNativeWindowLast() { string source = GameWindowSource(); + string program = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Program.cs")); string lifetime = GameWindowLifetimeSource(); string manifest = Slice( lifetime, @@ -464,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests [ "new ResourceShutdownStage(\"host and session barriers\"", "new ResourceShutdownStage(\"physical ingress cleanup\"", + "new ResourceShutdownStage(\"plugin host\"", "new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"live entities\"", @@ -499,6 +505,8 @@ public sealed class GameWindowSlice8BoundaryTests "Soft(\"gameplay actions\", () => DisposeGameplayActions(ingress.GameplayActions))", "Soft(\"camera pointer\", () => DisposeCameraPointer(ingress.CameraPointer))", "Soft(\"native window callbacks\", () => DisposeWindowCallbacks(ingress.WindowCallbacks))", + "new ResourceShutdownStage(\"plugin host\"", + "Hard(\"plugins\", () => ingress.Plugins?.Dispose())", "new ResourceShutdownStage(\"session dependents\"", "Hard(\"mouse capture\", () => live.CameraPointer?.ReleaseMouseLookAfterSessionRetirement())"); Assert.Contains( @@ -509,7 +517,27 @@ public sealed class GameWindowSlice8BoundaryTests "UiHost? RetainedUiHost,", lifetime, StringComparison.Ordinal); + Assert.Contains( + "IDisposable? Plugins,", + lifetime, + StringComparison.Ordinal); Assert.Contains("_uiHost,", source, StringComparison.Ordinal); + Assert.Contains("_pluginSession,", source, StringComparison.Ordinal); + AssertAppearsInOrder( + program, + "GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(", + "window.StartPluginHosting(pluginSession);", + "window.Run();"); + AssertAppearsInOrder( + source, + "_pluginSession = pluginSession;", + "pluginSession.Start();", + "public void Run()"); + AssertAppearsInOrder( + manifest, + "Hard(\"plugins\", () => ingress.Plugins?.Dispose())", + "Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))", + "Hard(\"game runtime\", () => DisposeGameRuntime(live.Runtime))"); AssertAppearsInOrder( nativeRelease, "TryComplete();", diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index 130dc6d3..e2aa76ad 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Net; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Headless.Configuration; @@ -7,6 +8,9 @@ using AcDream.Headless.Diagnostics; using AcDream.Headless.Hosting; using AcDream.Headless.Plugins; using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Session; +using AcDream.Tests.Fixtures.CampaignLa; namespace AcDream.Headless.Tests; @@ -14,6 +18,7 @@ public sealed class HeadlessPluginSessionTests { private const string FixtureId = "acdream.test.host-fixture"; private const string BrokenId = "acdream.test.broken"; + private const string ThrowingId = "acdream.test.throwing-fixture"; [Fact] public void ConfiguredSetBorrowsRuntimeUsesNoOpUiIsolatesFailureAndUnloads() @@ -29,8 +34,10 @@ public sealed class HeadlessPluginSessionTests Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath), credential, diagnostics, + new FixtureSessionOperations(), pluginRoots: [temporary.Path]); HeadlessPluginSession plugins = session.Plugins; + _ = session.Start(); _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); Assert.Equal(1, plugins.LoadedCount); @@ -47,12 +54,17 @@ public sealed class HeadlessPluginSessionTests Assert.Equal(2, plugins.Host.State.Entities.Count); JsonElement[] statuses = ReadStatuses(statusPath); - Assert.Equal(["pluginLoaded", "pluginFailed"], EventNames(statuses)); - Assert.Equal(FixtureId, statuses[0].GetProperty("plugin").GetString()); - Assert.Equal(BrokenId, statuses[1].GetProperty("plugin").GetString()); + Assert.Equal( + [ + "started", "pluginLoaded", "pluginFailed", "connected", + "characterList", "enteredWorld", + ], + EventNames(statuses)); + Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString()); + Assert.Equal(BrokenId, statuses[2].GetProperty("plugin").GetString()); Assert.Contains( "entry dll not found", - statuses[1].GetProperty("error").GetString()!, + statuses[2].GetProperty("error").GetString()!, StringComparison.OrdinalIgnoreCase); Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString()); @@ -79,13 +91,126 @@ public sealed class HeadlessPluginSessionTests Descriptor([], statusPath), credential, new HeadlessDiagnosticWriter(output), + new FixtureSessionOperations(), pluginRoots: [temporary.Path]); + _ = session.Start(); Assert.Equal(0, session.Plugins.LoadedCount); - Assert.False(File.Exists(statusPath)); + Assert.Equal( + ["started", "connected", "characterList", "enteredWorld"], + EventNames(ReadStatuses(statusPath))); Assert.DoesNotContain("fixture-", output.ToString()); } + [Fact] + public void ThrowAfterRegistrationRollsBackEventsAndCollectsContext() + { + using var temporary = new TemporaryDirectory(); + string pluginDirectory = InstallFixture( + temporary.Path, + ThrowingId, + "throwing-fixture"); + File.WriteAllText( + Path.Combine(pluginDirectory, "throw-after-register"), + string.Empty); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + Descriptor([ThrowingId], statusPath), + credential, + new HeadlessDiagnosticWriter(new StringWriter()), + new FixtureSessionOperations(), + pluginRoots: [temporary.Path]); + + _ = session.Start(); + Assert.Equal(0, session.Plugins.LoadedCount); + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + Assert.False(File.Exists( + Path.Combine(pluginDirectory, "unexpected-callback"))); + Assert.Equal( + [ + "started", "pluginFailed", "connected", "characterList", + "enteredWorld", + ], + EventNames(ReadStatuses(statusPath))); + + WeakReference context = Assert.Single( + session.Plugins.CaptureLoadContextWeakReferences()); + session.Dispose(); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void LauncherProbeRoundTripKeepsPluginsDisabledInTheRealHost() + { + using var temporary = new TemporaryDirectory(); + InstallFixture(temporary.Path, FixtureId); + string configPath = Path.Combine(temporary.Path, "probe.json"); + File.WriteAllText( + configPath, + LauncherCoreSessionConfigFixture.ComposeProbe()); + HeadlessSessionDescriptor descriptor = Assert.Single( + HeadlessConfigurationLoader.Load(configPath).Sessions)! with + { + StatusFile = Path.Combine(temporary.Path, "probe-status.jsonl"), + }; + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + descriptor, + credential, + new HeadlessDiagnosticWriter(new StringWriter()), + new FixtureSessionOperations(), + pluginRoots: [temporary.Path]); + + RuntimeSessionStartResult result = session.Start(); + + Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, result.Status); + Assert.NotNull(descriptor.Plugins); + Assert.Empty(descriptor.Plugins); + Assert.Equal(0, session.Plugins.LoadedCount); + Assert.Equal( + ["started", "connected", "characterList"], + EventNames(ReadStatuses(descriptor.StatusFile!))); + } + + [Fact] + public async Task LateSubscriberReplayQueuesConcurrentRegistrationExactlyOnceInOrder() + { + using var temporary = new TemporaryDirectory(); + var credential = new HeadlessCredentialSecret("fixture", "password"); + using var session = new HeadlessSessionHost( + Descriptor([], Path.Combine(temporary.Path, "status.jsonl")), + credential, + new HeadlessDiagnosticWriter(new StringWriter()), + new FixtureSessionOperations(), + pluginRoots: [temporary.Path]); + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + HeadlessPluginHost host = session.Plugins.Host; + using var replayCaptured = new ManualResetEventSlim(); + using var releaseReplay = new ManualResetEventSlim(); + host.ReplayCapturedForTest = () => + { + replayCaptured.Set(); + Assert.True(releaseReplay.Wait(TimeSpan.FromSeconds(10))); + }; + var observed = new List(); + Action handler = snapshot => + { + lock (observed) + observed.Add(snapshot.Id); + }; + + Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler); + Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10))); + _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); + releaseReplay.Set(); + await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); + host.Events.EntitySpawned -= handler; + + Assert.Equal([1_000_000u, 1_000_001u], observed); + } + private static HeadlessSessionDescriptor Descriptor( List plugins, string statusPath) => new() @@ -144,15 +269,19 @@ public sealed class HeadlessPluginSessionTests private static string[] EventNames(IEnumerable events) => events.Select(static item => item.GetProperty("e").GetString()!).ToArray(); - private static void InstallFixture(string root, string id) + private static string InstallFixture( + string root, + string id, + string directoryName = "host-fixture") { string source = FixtureAssemblyPath(); Assert.True(File.Exists(source), $"fixture DLL not found: {source}"); - string pluginDirectory = Path.Combine(root, "host-fixture"); + string pluginDirectory = Path.Combine(root, directoryName); Directory.CreateDirectory(pluginDirectory); string fileName = Path.GetFileName(source); File.Copy(source, Path.Combine(pluginDirectory, fileName)); WriteManifest(pluginDirectory, id, fileName); + return pluginDirectory; } private static void InstallBrokenPlugin(string root, string id) @@ -214,6 +343,39 @@ public sealed class HeadlessPluginSessionTests } } + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + private static readonly CharacterList.Parsed Characters = new( + 0u, + [new CharacterList.Character(0x50000001u, "Fixture", 0u)], + [], + 1, + "account", + true, + true); + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint); + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters; + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } + private sealed class TemporaryDirectory : IDisposable { internal TemporaryDirectory() @@ -228,8 +390,22 @@ public sealed class HeadlessPluginSessionTests public void Dispose() { - if (Directory.Exists(Path)) - Directory.Delete(Path, recursive: true); + for (int attempt = 0; Directory.Exists(Path); attempt++) + { + try + { + Directory.Delete(Path, recursive: true); + return; + } + catch (Exception error) + when (error is IOException or UnauthorizedAccessException + && attempt < 9) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + Thread.Sleep(10); + } + } } } } diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs index 44e32af9..bc410558 100644 --- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -60,6 +60,33 @@ public sealed class SessionConfigurationSharedFixtureTests StringComparison.Ordinal); } + [Fact] + public void HeadlessReaderPreservesLauncherExplicitEmptyPluginAllowList() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.ComposeEmptyPlugins()); + + HeadlessSessionDescriptor session = Assert.Single( + HeadlessConfigurationLoader.Load(file.Path).Sessions)!; + + Assert.NotNull(session.Plugins); + Assert.Empty(session.Plugins); + } + + [Fact] + public void HeadlessReaderPreservesProbeLoadNoneAllowList() + { + using TemporaryFile file = TemporaryFile.Create( + LauncherCoreSessionConfigFixture.ComposeProbe()); + + HeadlessSessionDescriptor session = Assert.Single( + HeadlessConfigurationLoader.Load(file.Path).Sessions)!; + + Assert.Equal(HeadlessSessionMode.Probe, session.Mode); + Assert.NotNull(session.Plugins); + Assert.Empty(session.Plugins); + } + [Fact] public void HeadlessReaderAcceptsTheProductionShapedSharedFixture() { diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs index 26bd3c0a..4baeae42 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs @@ -175,7 +175,7 @@ public sealed class SessionConfigComposerTests } [Fact] - public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays() + public void EmptyPluginsRemainAnExplicitLoadNoneAllowListWhileLoginCommandsAreOmitted() { CharacterProfile character = Character(LaunchMode.Gui); character.Plugins = []; @@ -190,7 +190,8 @@ public sealed class SessionConfigComposerTests sessionId: "session-empty-lists"); JsonObject session = SingleSession(composed); - Assert.False(session.ContainsKey("plugins")); + Assert.True(session.ContainsKey("plugins")); + Assert.Empty(session["plugins"]!.AsArray()); Assert.False(session.ContainsKey("loginCommands")); } @@ -243,7 +244,7 @@ public sealed class SessionConfigComposerTests } [Fact] - public void ProbeModeSetsModeAndOmitsCharacterPolicyPluginsAndLoginCommands() + public void ProbeModeSetsModeAndCarriesExplicitLoadNonePluginAllowList() { ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( Server(), @@ -256,7 +257,7 @@ public sealed class SessionConfigComposerTests AssertKeys( session, - "id", "mode", "endpoint", "account", "credential", "statusFile"); + "id", "mode", "endpoint", "account", "credential", "plugins", "statusFile"); Assert.Equal("session-probe", (string?)session["id"]); Assert.Equal("probe", (string?)session["mode"]); @@ -266,7 +267,7 @@ public sealed class SessionConfigComposerTests Assert.Equal("standardInput", (string?)session["credential"]!["provider"]); Assert.False(session.ContainsKey("character")); Assert.False(session.ContainsKey("policy")); - Assert.False(session.ContainsKey("plugins")); + Assert.Empty(session["plugins"]!.AsArray()); Assert.False(session.ContainsKey("loginCommands")); Assert.False(session.ContainsKey("loginCommandDelayMs")); Assert.Equal( diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs index 9a1a9143..5349fcd7 100644 --- a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs @@ -11,11 +11,17 @@ namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; public sealed class HostPlugin : IAcDreamPlugin { private IPluginHost? _host; + private string? _assemblyDirectory; + private bool _throwAfterRegistration; private int _entitiesSeen; public void Initialize(IPluginHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); + _assemblyDirectory = Path.GetDirectoryName( + typeof(HostPlugin).Assembly.Location); + _throwAfterRegistration = File.Exists( + Path.Combine(_assemblyDirectory!, "throw-after-register")); host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); } @@ -27,6 +33,11 @@ public sealed class HostPlugin : IAcDreamPlugin Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), this); host.Events.EntitySpawned += OnEntitySpawned; + if (_throwAfterRegistration) + { + throw new InvalidOperationException( + "fixture enable failed after registering UI and events"); + } host.Log.Info( $"fixture-enabled:hasUi={host.HasUi}:entities={host.State.Entities.Count}"); } @@ -36,11 +47,24 @@ public sealed class HostPlugin : IAcDreamPlugin IPluginHost? host = _host; if (host is null) return; + if (_throwAfterRegistration) + { + throw new InvalidOperationException( + "fixture disable intentionally refuses cleanup"); + } host.Events.EntitySpawned -= OnEntitySpawned; host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); _host = null; } - private void OnEntitySpawned(WorldEntitySnapshot snapshot) => + private void OnEntitySpawned(WorldEntitySnapshot snapshot) + { _entitiesSeen++; + if (_throwAfterRegistration && _assemblyDirectory is not null) + { + File.AppendAllText( + Path.Combine(_assemblyDirectory, "unexpected-callback"), + $"{snapshot.Id}{Environment.NewLine}"); + } + } } diff --git a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs index 71b6581e..cc841868 100644 --- a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs +++ b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs @@ -55,4 +55,66 @@ internal static class LauncherCoreSessionConfigFixture return SessionConfigComposer.Serialize(composed.Document); } + + internal static string ComposeEmptyPlugins() + { + (ServerProfile server, AccountProfile account, + LauncherInstallRecord install, ApplicationPathSet paths) = Inputs(); + var character = new CharacterProfile + { + Name = "Composer Character", + Id = "0x50000001", + LaunchMode = LaunchMode.Headless, + Plugins = [], + LoginCommands = [], + }; + + ComposedSessionConfig composed = SessionConfigComposer.Compose( + server, + account, + character, + install, + paths, + "composer-empty-plugins"); + return SessionConfigComposer.Serialize(composed.Document); + } + + internal static string ComposeProbe() + { + (ServerProfile server, AccountProfile account, + LauncherInstallRecord install, ApplicationPathSet paths) = Inputs(); + ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( + server, + account, + install, + paths, + "composer-probe"); + return SessionConfigComposer.Serialize(composed.Document); + } + + private static ( + ServerProfile Server, + AccountProfile Account, + LauncherInstallRecord Install, + ApplicationPathSet Paths) Inputs() => + ( + new ServerProfile + { + Name = "Composer Server", + Host = "composer.example", + Port = 9010, + }, + new AccountProfile + { + Account = "composer-account", + Password = Password, + }, + new LauncherInstallRecord( + "composer-dats", + "composer-dats/acdream.pak"), + new ApplicationPathSet( + Path.Combine(Path.GetTempPath(), "composer-config"), + Path.Combine(Path.GetTempPath(), "composer-data"), + Path.Combine(Path.GetTempPath(), "composer-cache"), + LegacyConfigDirectory: null)); } From ae2cbbee8c06aa98b71771980dca2543077a616c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:12:32 +0200 Subject: [PATCH 036/138] fix(launcher): require executable Linux hosts --- .github/workflows/headless-portability.yml | 7 ++ .../Orchestration/LauncherExecutableSet.cs | 53 ++++++++++--- .../LauncherExecutableSetTests.cs | 76 +++++++++++++++++++ .../LauncherOrchestratorTests.cs | 3 +- .../LauncherProjectBoundaryTests.cs | 5 ++ 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 3fae2923..801f3476 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -135,6 +135,13 @@ jobs: dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Verify Linux headless host executable permission + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless + portable-launcher: strategy: fail-fast: false diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index a02496ce..ef4635c5 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -12,12 +12,14 @@ namespace AcDream.Launcher.Core.Orchestration; public sealed class LauncherExecutableSet { private readonly Func _fileExists; + private readonly Func _hasUnixExecutePermission; public LauncherExecutableSet( string graphicalHostPath, string headlessHostPath, string? workingDirectory = null, - Func? fileExists = null) + Func? fileExists = null, + Func? hasUnixExecutePermission = null) { ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath); ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath); @@ -25,6 +27,8 @@ public sealed class LauncherExecutableSet HeadlessHostPath = headlessHostPath; WorkingDirectory = workingDirectory; _fileExists = fileExists ?? File.Exists; + _hasUnixExecutePermission = + hasUnixExecutePermission ?? HasUnixExecutePermission; } public string GraphicalHostPath { get; } @@ -38,17 +42,25 @@ public sealed class LauncherExecutableSet string path = mode == LaunchMode.Headless ? HeadlessHostPath : GraphicalHostPath; - if (_fileExists(path)) - { - return LauncherCapability.Available; - } - string host = mode == LaunchMode.Headless ? "headless host" : "graphical client"; - return LauncherCapability.Unavailable( - $"The co-deployed {host} is missing at '{path}'. Reinstall or update " - + "the client before launching."); + if (!_fileExists(path)) + { + return LauncherCapability.Unavailable( + $"The co-deployed {host} is missing at '{path}'. Reinstall or update " + + "the client before launching."); + } + + if (OperatingSystem.IsLinux() && !_hasUnixExecutePermission(path)) + { + return LauncherCapability.Unavailable( + $"The co-deployed {host} at '{path}' exists but is not executable. " + + "Restore its executable permission (for example, chmod +x) or " + + "reinstall/update the client before launching."); + } + + return LauncherCapability.Available; } public LauncherProcessSpec CreatePlaySpec( @@ -99,4 +111,27 @@ public sealed class LauncherExecutableSet capability.Reason ?? "The selected launcher host is unavailable."); } } + + private static bool HasUnixExecutePermission(string path) + { + if (!OperatingSystem.IsLinux()) + { + return true; + } + + try + { + const UnixFileMode executeBits = + UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & executeBits) != 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Fail closed if the file vanished or its metadata cannot be read + // after the existence check. The next capability refresh retries. + return false; + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs index c275cc93..3d146ffd 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs @@ -27,6 +27,8 @@ public sealed class LauncherExecutableSetTests : IDisposable string headless = Path.Combine(_root, "acdream-headless" + suffix); File.WriteAllText(graphical, string.Empty); File.WriteAllText(headless, string.Empty); + MakeExecutableOnLinux(graphical); + MakeExecutableOnLinux(headless); LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root); @@ -64,4 +66,78 @@ public sealed class LauncherExecutableSetTests : IDisposable Assert.Throws(() => set.CreateProbeSpec("session.json")); } + + [Fact] + public void LinuxRequiresExecutePermissionForBothCoDeployedHosts() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + Directory.CreateDirectory(_root); + string graphical = Path.Combine(_root, "AcDream.App"); + string headless = Path.Combine(_root, "acdream-headless"); + File.WriteAllText(graphical, string.Empty); + File.WriteAllText(headless, string.Empty); + UnixFileMode notExecutable = UnixFileMode.UserRead | UnixFileMode.UserWrite + | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + File.SetUnixFileMode(graphical, notExecutable); + File.SetUnixFileMode(headless, notExecutable); + LauncherExecutableSet set = LauncherExecutableSet.FromDirectory(_root); + + LauncherCapability gui = set.GetAvailability(LaunchMode.Gui); + LauncherCapability headlessCapability = + set.GetAvailability(LaunchMode.Headless); + + Assert.False(gui.IsAvailable); + Assert.Contains("not executable", gui.Reason, StringComparison.Ordinal); + Assert.Contains("chmod +x", gui.Reason, StringComparison.Ordinal); + Assert.False(headlessCapability.IsAvailable); + Assert.Contains("not executable", headlessCapability.Reason, StringComparison.Ordinal); + Assert.Throws(() => + set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json")); + Assert.Throws(() => + set.CreateProbeSpec("session.json")); + + MakeExecutableOnLinux(graphical); + MakeExecutableOnLinux(headless); + + Assert.True(set.GetAvailability(LaunchMode.GuiSelect).IsAvailable); + Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable); + } + + [Fact] + public void WindowsPreservesExistenceOnlyAvailability() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var set = new LauncherExecutableSet( + "graphical.exe", + "headless.exe", + fileExists: _ => true, + hasUnixExecutePermission: _ => false); + + Assert.True(set.GetAvailability(LaunchMode.Gui).IsAvailable); + Assert.True(set.GetAvailability(LaunchMode.Headless).IsAvailable); + } + + private static void MakeExecutableOnLinux(string path) + { + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute); + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs index 14cb64e5..57ab95ed 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs @@ -518,7 +518,8 @@ public sealed class LauncherOrchestratorTests : IDisposable executables ?? new LauncherExecutableSet( "gui-host", "headless-host", - fileExists: _ => true), + fileExists: _ => true, + hasUnixExecutePermission: _ => true), new LauncherInstallRecord("dats", "pak"), platform ?? WindowsCapabilities(), configService, diff --git a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs index 456b799b..b2dc593a 100644 --- a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs @@ -116,6 +116,11 @@ public sealed class LauncherProjectBoundaryTests Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal); Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal); Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal); + Assert.Contains( + "test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless", + workflow, + StringComparison.Ordinal); + Assert.Contains("test -x \"$root/AcDream.App\"", workflow, StringComparison.Ordinal); } private static string EvaluateProperty(string projectPath, string property) From f820eb258d4cd8dc30364de121b72d1cbac29b7d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:28:14 +0200 Subject: [PATCH 037/138] fix(plugins): close LA5 ownership races --- docs/plans/2026-08-14-launcher-campaign.md | 10 +- .../2026-08-14-launcher-campaign-design.md | 4 +- src/AcDream.Core/Plugins/LoadedPlugin.cs | 14 +- src/AcDream.Core/Plugins/PluginLoader.cs | 28 ++- src/AcDream.Core/Plugins/PluginSession.cs | 42 ++++- src/AcDream.Core/Plugins/ScopedPluginHost.cs | 105 ++++++++++- .../Plugins/HeadlessPluginHost.cs | 21 ++- .../Launching/SessionConfigDocument.cs | 6 +- .../Entities/RuntimeEntityDirectory.cs | 176 ++++++++++++------ .../Entities/RuntimeEntityObjectViews.cs | 4 + .../Plugins/GraphicalPluginSessionTests.cs | 64 ++++++- .../Plugins/PluginLoaderTests.cs | 2 + .../HeadlessPluginSessionTests.cs | 24 ++- .../HostPlugin.cs | 74 +++++++- 14 files changed, 467 insertions(+), 107 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 00913fb2..0de911ff 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -155,7 +155,11 @@ Field rules: for gui/guiSelect/probe. - `credential`: always `{ "provider": "standardInput", "reference": "session" }` for launcher-composed configs. -- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional, +- `plugins`: absent/null means load all discovered plugins (preserving the + developer flow); explicit `[]` means load none. Launcher-composed + normal-empty and probe sessions emit `[]` so they cannot load arbitrary + machine-local plugins. +- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional, omitted-when-unset (never null, never `[]` for empty). Absent `loginCommandDelayMs` means 500. @@ -297,7 +301,9 @@ are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is genuinely App-only. Headless has zero plugin hosting today (confirmed). 1. Session-config `Plugins` allow-list filters the discovery result on BOTH - hosts (absent list = load all, preserving today's dev behavior). + hosts (absent/null list = load all, preserving today's dev behavior; + explicit `[]` = load none). Launcher-composed normal-empty and probe + sessions emit `[]`. 2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned `State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index 42360f1d..fb4a37d6 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -168,7 +168,9 @@ Hand-editability is a property of the format, not a required workflow. `HeadlessConfiguration` shape extended with: - `Plugins: string[]` — plugin names to load from the standard - `PluginsDirectory`; hosts load exactly this set. + `PluginsDirectory`; absent/null loads all discovered plugins, while an + explicit empty array loads none. Launcher-composed normal-empty and probe + sessions emit the empty array. - `LoginCommands: string[]` — ordered chat-typed strings. - Graphical host: `Character` selector may be ABSENT → character-select screen instead of auto-enter. diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs index ad48f6db..9f1f534a 100644 --- a/src/AcDream.Core/Plugins/LoadedPlugin.cs +++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs @@ -7,17 +7,17 @@ namespace AcDream.Core.Plugins; /// Outcome of a plugin load attempt. /// On success, is the instantiated plugin, /// owns its assembly, and is null. -/// On failure, and are null, -/// describes what went wrong, and -/// weakly observes any collectible context -/// that was already released during rollback. +/// On failure, describes what went wrong. A partial +/// and/or may still be present; +/// the caller owns their cleanup. The loader never requests collectible unload +/// itself because the session must first roll back host registrations. /// public sealed record LoadedPlugin( PluginManifest Manifest, IAcDreamPlugin? Plugin, AssemblyLoadContext? LoadContext, - Exception? Error, - WeakReference? ReleasedLoadContext = null) + Exception? Error) { - public bool Success => Plugin is not null && Error is null; + public bool Success => + Plugin is not null && LoadContext is not null && Error is null; } diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index 54042a51..1d729f2a 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -11,6 +11,8 @@ public static class PluginLoader /// implementing , instantiate it, and call its /// with the supplied host. Any failure /// is returned as a failed rather than thrown. + /// A returned partial plugin/context remains caller-owned; this method never + /// requests unload because the caller must close host registrations first. /// public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) { @@ -48,15 +50,12 @@ public static class PluginLoader if (pluginType is null) { - var released = new WeakReference(alc); - alc.Unload(); return new LoadedPlugin( manifest, Plugin: null, - LoadContext: null, + LoadContext: alc, Error: new InvalidOperationException( - $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"), - ReleasedLoadContext: released); + $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); } instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; @@ -65,20 +64,15 @@ public static class PluginLoader } catch (Exception ex) { - // Initialize may have attached host callbacks before it failed. - // Give that partial instance the same best-effort cleanup chance - // as an Enable failure before releasing the collectible context. - try { instance?.Disable(); } - catch { } - WeakReference? released = alc is null ? null : new WeakReference(alc); - try { alc?.Unload(); } - catch { } + // The caller owns rollback for a partial instance/context. In + // particular, Initialize may already have attached host callbacks; + // the per-plugin host scope must remove those registrations before + // Disable or any collectible unload request can run. return new LoadedPlugin( manifest, - Plugin: null, - LoadContext: null, - Error: ex, - ReleasedLoadContext: released); + Plugin: instance, + LoadContext: alc, + Error: ex); } } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index dfbbb3ef..1436fb6b 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -214,9 +214,11 @@ public sealed class PluginSession : IDisposable scope); if (!loaded.Success) { + // Initialize can register callbacks before it fails. The + // registration transaction closes before plugin cleanup + // and, critically, before any ALC Unloading notification. scope.Dispose(); - if (loaded.ReleasedLoadContext is { } released) - _releasedContexts.Add(released); + ReleaseFailedLoad(loaded); AddError( errors, id, @@ -304,6 +306,42 @@ public sealed class PluginSession : IDisposable } } + private void ReleaseFailedLoad(LoadedPlugin loaded) + { + if (loaded.Plugin is not null) + { + try + { + loaded.Plugin.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin cleanup after initialize failure failed: {loaded.Manifest.Id}", + error); + } + } + + if (loaded.LoadContext is null) + return; + + _releasedContexts.Add(new WeakReference(loaded.LoadContext)); + try + { + loaded.LoadContext.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload after load failure failed: {loaded.Manifest.Id}", + error); + } + } + private void Report(PluginSessionStatus status) { if (_report is null) diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 61786b22..ee1667f1 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -4,13 +4,14 @@ namespace AcDream.Core.Plugins; /// /// Per-plugin host view that owns every registration made through the public -/// event/UI surfaces. Disposal is the host's rollback boundary: it removes +/// event/selection/UI surfaces. Disposal is the host's rollback boundary: it removes /// registrations even when plugin Initialize/Enable/Disable code throws. /// internal sealed class ScopedPluginHost : IPluginHost, IDisposable { private readonly IPluginHost _inner; private readonly ScopedEvents _events; + private readonly ScopedSelectionService _selection; private readonly ScopedUiRegistry _ui; private bool _disposed; @@ -18,6 +19,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _events = new ScopedEvents(inner.Events); + _selection = new ScopedSelectionService(inner.Selection); _ui = new ScopedUiRegistry(inner.Ui); } @@ -25,7 +27,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable public IPluginLogger Log => _inner.Log; public IGameState State => _inner.State; public IEvents Events => _events; - public ISelectionService Selection => _inner.Selection; + public ISelectionService Selection => _selection; public IUiRegistry Ui => _ui; public void Dispose() @@ -34,9 +36,108 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable return; _disposed = true; _events.Dispose(); + _selection.Dispose(); _ui.Dispose(); } + private sealed class ScopedSelectionService(ISelectionService inner) + : ISelectionService, + IDisposable + { + private readonly object _gate = new(); + private readonly List> _registrations = []; + private bool _disposed; + + public uint? SelectedObjectId => inner.SelectedObjectId; + public uint? PreviousObjectId => inner.PreviousObjectId; + + public event Action Changed + { + add + { + ArgumentNullException.ThrowIfNull(value); + try + { + inner.Changed += value; + } + catch + { + try { inner.Changed -= value; } + catch { } + throw; + } + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(value); + return; + } + } + + try { inner.Changed -= value; } + catch { } + throw new ObjectDisposedException(nameof(ScopedSelectionService)); + } + remove + { + if (value is null) + return; + inner.Changed -= value; + lock (_gate) + RemoveLast(value); + } + } + + public bool Select(uint objectId) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return inner.Select(objectId); + } + } + + public bool Clear() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return inner.Clear(); + } + } + + public void Dispose() + { + Action[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { inner.Changed -= registrations[index]; } + catch { } + } + } + + private void RemoveLast(Action handler) + { + for (int index = _registrations.Count - 1; index >= 0; index--) + { + if (_registrations[index] != handler) + continue; + _registrations.RemoveAt(index); + return; + } + } + } + private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable { private readonly object _gate = new(); diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 5eda1706..ca5b6d5a 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -52,8 +52,9 @@ internal sealed class HeadlessPluginHost public ISelectionService Selection => _runtime.ActionOwner.Selection; public IUiRegistry Ui => NoOpUiRegistry.Instance; - /// Test-only barrier after the borrowed replay snapshot is - /// captured and before delivery starts. + /// Test-only barrier invoked after the first replay item is + /// captured while Runtime's exact active-membership read lease is still + /// held. internal Action? ReplayCapturedForTest { get; set; } /// @@ -88,11 +89,12 @@ internal sealed class HeadlessPluginHost // Arm the pending queue before borrowing Runtime's snapshot. This // avoids a host-lock/Runtime-lock inversion while the identity // dedup below collapses any registration present in both views. - var visitor = new SnapshotVisitor(_runtime); + var visitor = new SnapshotVisitor( + _runtime, + ReplayCapturedForTest); _runtime.Entities.Visit(visitor); ReplayEntity[] replay = visitor.Items.ToArray(); - ReplayCapturedForTest?.Invoke(); foreach (ReplayEntity item in replay) { lock (_eventGate) @@ -234,16 +236,23 @@ internal sealed class HeadlessPluginHost catch { } } - private sealed class SnapshotVisitor(GameRuntime runtime) + private sealed class SnapshotVisitor( + GameRuntime runtime, + Action? captureBarrier = null) : IRuntimeEntityVisitor { + private Action? _captureBarrier = captureBarrier; + internal List Items { get; } = new(runtime.Entities.Count); - public void Visit(in RuntimeEntitySnapshot entity) => + public void Visit(in RuntimeEntitySnapshot entity) + { Items.Add(new ReplayEntity( entity.Identity, Convert(runtime, entity))); + Interlocked.Exchange(ref _captureBarrier, null)?.Invoke(); + } } private void RebuildLiveSnapshotLocked() diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs index 0f013f53..1fc31f4d 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -98,8 +98,10 @@ public sealed class SessionDescriptor public SessionCredentialDescriptor Credential { get; init; } = new(); - /// Omitted (never an empty array) when the character has no - /// configured plugin set. + /// Plugin allow-list. Omitted or JSON null means load all + /// discovered plugins (the developer flow); an explicit empty array means + /// load none. Launcher-composed normal-empty and probe sessions therefore + /// emit []. public List? Plugins { get; init; } /// Omitted (never an empty array) when the character has no diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index fe733332..98dbd9cb 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -16,6 +16,7 @@ public sealed class RuntimeEntityDirectory public const uint LastLocalEntityId = 0x3FFF_FFFFu; private readonly InboundPhysicsStateController _inbound = new(); + private readonly object _activeGate = new(); private readonly Dictionary _activeByGuid = new(); private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord> _teardownByIncarnation = new(); @@ -35,10 +36,27 @@ public sealed class RuntimeEntityDirectory _nextLocalEntityId = firstLocalEntityId; } - public int Count => _activeByGuid.Count; + public int Count + { + get + { + lock (_activeGate) + return _activeByGuid.Count; + } + } public int PendingTeardownCount => _teardownByIncarnation.Count; - public int ClaimedLocalIdCount => _byLocalId.Count; + public int ClaimedLocalIdCount + { + get + { + lock (_activeGate) + return _byLocalId.Count; + } + } public ulong SessionLifetimeVersion { get; private set; } + /// Update-thread-only borrowed collection. Cross-thread hosts use + /// through IRuntimeEntityView.Visit + /// so membership cannot change during enumeration. public IReadOnlyCollection ActiveRecords => _activeByGuid.Values; public IReadOnlyCollection TeardownRecords => _teardownByIncarnation.Values; @@ -87,34 +105,48 @@ public sealed class RuntimeEntityDirectory public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot) { - if (_activeByGuid.ContainsKey(snapshot.Guid)) + lock (_activeGate) { - throw new InvalidOperationException( - $"Live entity 0x{snapshot.Guid:X8} already has an active incarnation."); - } + if (_activeByGuid.ContainsKey(snapshot.Guid)) + { + throw new InvalidOperationException( + $"Live entity 0x{snapshot.Guid:X8} already has an active incarnation."); + } - var record = new RuntimeEntityRecord(snapshot); - _activeByGuid.Add(snapshot.Guid, record); - try - { - ClaimLocalId(record); - return record; - } - catch - { - _activeByGuid.Remove(snapshot.Guid); - throw; + var record = new RuntimeEntityRecord(snapshot); + _activeByGuid.Add(snapshot.Guid, record); + try + { + ClaimLocalId(record); + return record; + } + catch + { + _activeByGuid.Remove(snapshot.Guid); + throw; + } } } - public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) => - _activeByGuid.Remove(guid, out record); + public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) + { + lock (_activeGate) + return _activeByGuid.Remove(guid, out record); + } public bool RemoveActive(RuntimeEntityRecord expected) { - if (!IsCurrent(expected)) - return false; - return _activeByGuid.Remove(expected.ServerGuid); + lock (_activeGate) + { + if (!_activeByGuid.TryGetValue( + expected.ServerGuid, + out RuntimeEntityRecord? current) + || !ReferenceEquals(current, expected)) + { + return false; + } + return _activeByGuid.Remove(expected.ServerGuid); + } } public void RetainTeardown(RuntimeEntityRecord record) @@ -157,46 +189,52 @@ public sealed class RuntimeEntityDirectory public uint ClaimLocalId(RuntimeEntityRecord record) { - if (!IsKnown(record)) + lock (_activeGate) { - throw new InvalidOperationException( - "A local id can only be claimed for an active or retained incarnation."); + if (!IsKnown(record)) + { + throw new InvalidOperationException( + "A local id can only be claimed for an active or retained incarnation."); + } + + if (record.LocalEntityId is { } existing) + return existing; + + uint start = _nextLocalEntityId; + do + { + uint candidate = _nextLocalEntityId; + _nextLocalEntityId = candidate == LastLocalEntityId + ? FirstLocalEntityId + : candidate + 1u; + if (_byLocalId.ContainsKey(candidate)) + continue; + + _byLocalId.Add(candidate, record); + record.LocalEntityId = candidate; + return candidate; + } + while (_nextLocalEntityId != start); + + throw new InvalidOperationException("The live entity id namespace is exhausted."); } - - if (record.LocalEntityId is { } existing) - return existing; - - uint start = _nextLocalEntityId; - do - { - uint candidate = _nextLocalEntityId; - _nextLocalEntityId = candidate == LastLocalEntityId - ? FirstLocalEntityId - : candidate + 1u; - if (_byLocalId.ContainsKey(candidate)) - continue; - - _byLocalId.Add(candidate, record); - record.LocalEntityId = candidate; - return candidate; - } - while (_nextLocalEntityId != start); - - throw new InvalidOperationException("The live entity id namespace is exhausted."); } public bool ReleaseLocalId(RuntimeEntityRecord record) { - if (record.LocalEntityId is not { } localId) - return false; - if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained) - && ReferenceEquals(retained, record)) + lock (_activeGate) { - _byLocalId.Remove(localId); - } + if (record.LocalEntityId is not { } localId) + return false; + if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained) + && ReferenceEquals(retained, record)) + { + _byLocalId.Remove(localId); + } - record.LocalEntityId = null; - return true; + record.LocalEntityId = null; + return true; + } } public ulong AdvanceLifetimeMutation(uint serverGuid) @@ -219,15 +257,39 @@ public sealed class RuntimeEntityDirectory public bool CompleteSessionClearIfConverged() { - if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0) - return false; + lock (_activeGate) + { + if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0) + return false; - _byLocalId.Clear(); + _byLocalId.Clear(); + } ParentAttachments.Clear(); _inbound.Clear(); return true; } + /// + /// Enters the exact active-membership read boundary. Add/remove and local-id + /// membership commits serialize behind this allocation-free lease; record + /// ownership remains canonical here and no copied gameplay collection is + /// introduced. + /// + internal ActiveReadLease AcquireActiveRead() => new(_activeGate); + + internal readonly struct ActiveReadLease : IDisposable + { + private readonly object _gate; + + internal ActiveReadLease(object gate) + { + _gate = gate; + Monitor.Enter(gate); + } + + public void Dispose() => Monitor.Exit(_gate); + } + public void RefreshSnapshot( RuntimeEntityRecord record, WorldSession.EntitySpawn accepted, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs index 26f27e23..8ac2d909 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs @@ -91,6 +91,8 @@ internal sealed class RuntimeEntityObjectViews uint serverGuid, out RuntimeEntitySnapshot entity) { + using RuntimeEntityDirectory.ActiveReadLease lease = + owner.AcquireActiveRead(); if (owner.TryGetActive( serverGuid, out RuntimeEntityRecord record)) @@ -106,6 +108,8 @@ internal sealed class RuntimeEntityObjectViews public void Visit(IRuntimeEntityVisitor visitor) { ArgumentNullException.ThrowIfNull(visitor); + using RuntimeEntityDirectory.ActiveReadLease lease = + owner.AcquireActiveRead(); foreach (RuntimeEntityRecord record in owner.ActiveRecords) { RuntimeEntitySnapshot entity = Snapshot(record); diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs index 8f4a2e10..97850c53 100644 --- a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -15,6 +15,8 @@ public sealed class GraphicalPluginSessionTests { private const string FixtureId = "acdream.test.host-fixture"; private const string ThrowingId = "acdream.test.throwing-fixture"; + private const string InitializeThrowingId = + "acdream.test.initialize-throwing-fixture"; [Fact] public void ConfiguredSetLoadsOnlyAllowedPluginAndReportsBothOutcomes() @@ -114,12 +116,13 @@ public sealed class GraphicalPluginSessionTests string.Empty); string statusPath = Path.Combine(temporary.Path, "status.jsonl"); var events = new WorldEvents(); + var selection = new SelectionState(); var ui = new BufferedUiRegistry(); var host = new AppPluginHost( new CapturingLogger(), new WorldGameState(), events, - new SelectionState(), + selection, ui); using GraphicalPluginSession plugins = GraphicalPluginSession.Create( @@ -138,6 +141,65 @@ public sealed class GraphicalPluginSessionTests 2u, default, System.Numerics.Quaternion.Identity)); + Assert.True(((ISelectionService)selection).Select(7u)); + Assert.False(File.Exists( + Path.Combine(pluginDirectory, "unexpected-callback"))); + Assert.Equal( + ["started", "pluginFailed"], + EventNames(ReadStatuses(statusPath))); + + WeakReference context = Assert.Single( + plugins.CaptureLoadContextWeakReferences()); + plugins.Dispose(); + Collect(context); + Assert.False(context.IsAlive); + } + + [Fact] + public void InitializeFailureRollsBackEveryRegistrationBeforeUnload() + { + using var temporary = new TemporaryDirectory(); + ApplicationPathSet paths = Paths(temporary.Path); + string pluginDirectory = InstallFixture( + paths.PluginsDirectory, + InitializeThrowingId, + "initialize-throwing-fixture"); + File.WriteAllText( + Path.Combine(pluginDirectory, "throw-during-initialize"), + string.Empty); + string statusPath = Path.Combine(temporary.Path, "status.jsonl"); + var events = new WorldEvents(); + var selection = new SelectionState(); + var ui = new BufferedUiRegistry(); + var host = new AppPluginHost( + new CapturingLogger(), + new WorldGameState(), + events, + selection, + ui); + + using GraphicalPluginSession plugins = GraphicalPluginSession.Create( + paths, + [InitializeThrowingId], + "gui-session", + host, + new SessionStatusWriter(statusPath)); + plugins.Start(); + + Assert.Equal(0, plugins.LoadedCount); + Assert.Empty(ui.Drain()); + Assert.Equal(0, ui.RegistrationCount); + Assert.Equal( + "ui=True;events=True;selection=True", + File.ReadAllText(Path.Combine( + pluginDirectory, + "unload-observation"))); + events.FireEntitySpawned(new WorldEntitySnapshot( + 1u, + 2u, + default, + System.Numerics.Quaternion.Identity)); + Assert.True(((ISelectionService)selection).Select(9u)); Assert.False(File.Exists( Path.Combine(pluginDirectory, "unexpected-callback"))); Assert.Equal( diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index 3deebb94..e35755a7 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -125,5 +125,7 @@ public class PluginLoaderTests Assert.False(loaded.Success); Assert.Contains("IAcDreamPlugin", loaded.Error!.Message); + Assert.NotNull(loaded.LoadContext); + loaded.LoadContext!.Unload(); } } diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index e2aa76ad..c2f332ef 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -125,6 +125,8 @@ public sealed class HeadlessPluginSessionTests _ = session.Start(); Assert.Equal(0, session.Plugins.LoadedCount); _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000001u, 1f)); + Assert.True(((ISelectionService)session.Runtime.ActionOwner.Selection) + .Select(7u)); Assert.False(File.Exists( Path.Combine(pluginDirectory, "unexpected-callback"))); Assert.Equal( @@ -203,9 +205,25 @@ public sealed class HeadlessPluginSessionTests Task subscribe = Task.Run(() => host.Events.EntitySpawned += handler); Assert.True(replayCaptured.Wait(TimeSpan.FromSeconds(10))); - _ = session.Runtime.EntityObjects.RegisterEntity(Spawn(0x50000002u, 2f)); - releaseReplay.Set(); - await subscribe.WaitAsync(TimeSpan.FromSeconds(10)); + using var registrationStarted = new ManualResetEventSlim(); + Task registration = Task.Run(() => + { + registrationStarted.Set(); + _ = session.Runtime.EntityObjects.RegisterEntity( + Spawn(0x50000002u, 2f)); + }); + Assert.True(registrationStarted.Wait(TimeSpan.FromSeconds(10))); + try + { + await Task.Delay(TimeSpan.FromMilliseconds(100)); + Assert.False(registration.IsCompleted); + } + finally + { + releaseReplay.Set(); + } + await Task.WhenAll(subscribe, registration) + .WaitAsync(TimeSpan.FromSeconds(10)); host.Events.EntitySpawned -= handler; Assert.Equal([1_000_000u, 1_000_001u], observed); diff --git a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs index 5349fcd7..b294f697 100644 --- a/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs +++ b/tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/HostPlugin.cs @@ -1,4 +1,5 @@ using AcDream.Plugin.Abstractions; +using System.Runtime.Loader; namespace AcDream.Plugin.Tests.Fixtures.HostPlugin; @@ -13,6 +14,7 @@ public sealed class HostPlugin : IAcDreamPlugin private IPluginHost? _host; private string? _assemblyDirectory; private bool _throwAfterRegistration; + private bool _throwDuringInitialize; private int _entitiesSeen; public void Initialize(IPluginHost host) @@ -22,17 +24,24 @@ public sealed class HostPlugin : IAcDreamPlugin typeof(HostPlugin).Assembly.Location); _throwAfterRegistration = File.Exists( Path.Combine(_assemblyDirectory!, "throw-after-register")); + _throwDuringInitialize = File.Exists( + Path.Combine(_assemblyDirectory!, "throw-during-initialize")); host.Log.Info($"fixture-initialized:hasUi={host.HasUi}"); + if (_throwDuringInitialize) + { + RegisterHostCallbacks(host); + AssemblyLoadContext.GetLoadContext(typeof(HostPlugin).Assembly)! + .Unloading += OnUnloading; + throw new InvalidOperationException( + "fixture initialize failed after registering UI, entity, and selection callbacks"); + } } public void Enable() { IPluginHost host = _host ?? throw new InvalidOperationException("The fixture was not initialized."); - host.Ui.AddMarkupPanel( - Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), - this); - host.Events.EntitySpawned += OnEntitySpawned; + RegisterHostCallbacks(host); if (_throwAfterRegistration) { throw new InvalidOperationException( @@ -47,24 +56,75 @@ public sealed class HostPlugin : IAcDreamPlugin IPluginHost? host = _host; if (host is null) return; - if (_throwAfterRegistration) + if (_throwAfterRegistration || _throwDuringInitialize) { throw new InvalidOperationException( "fixture disable intentionally refuses cleanup"); } host.Events.EntitySpawned -= OnEntitySpawned; + host.Selection.Changed -= OnSelectionChanged; host.Log.Info($"fixture-disabled:entitiesSeen={_entitiesSeen}"); _host = null; } + private void RegisterHostCallbacks(IPluginHost host) + { + host.Ui.AddMarkupPanel( + Path.Combine(AppContext.BaseDirectory, "fixture-panel.xml"), + this); + host.Events.EntitySpawned += OnEntitySpawned; + host.Selection.Changed += OnSelectionChanged; + } + private void OnEntitySpawned(WorldEntitySnapshot snapshot) { _entitiesSeen++; - if (_throwAfterRegistration && _assemblyDirectory is not null) + RecordUnexpectedCallback(snapshot.Id); + } + + private void OnSelectionChanged(SelectionChangedEvent change) => + RecordUnexpectedCallback(change.SelectedObjectId ?? 0u); + + private void RecordUnexpectedCallback(uint objectId) + { + if ((_throwAfterRegistration || _throwDuringInitialize) + && _assemblyDirectory is not null) { File.AppendAllText( Path.Combine(_assemblyDirectory, "unexpected-callback"), - $"{snapshot.Id}{Environment.NewLine}"); + $"{objectId}{Environment.NewLine}"); + } + } + + private void OnUnloading(AssemblyLoadContext context) + { + IPluginHost host = _host!; + bool uiClosed = Rejects(() => host.Ui.AddMarkupPanel( + Path.Combine(AppContext.BaseDirectory, "unloading-panel.xml"), + this)); + bool eventsClosed = Rejects(() => + { + host.Events.EntitySpawned += OnEntitySpawned; + }); + bool selectionClosed = Rejects(() => + { + host.Selection.Changed += OnSelectionChanged; + }); + File.WriteAllText( + Path.Combine(_assemblyDirectory!, "unload-observation"), + $"ui={uiClosed};events={eventsClosed};selection={selectionClosed}"); + } + + private static bool Rejects(Action action) + { + try + { + action(); + return false; + } + catch (ObjectDisposedException) + { + return true; } } } From 267804465e86bda1743c2d9f75e055f462669bfc Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 19:36:00 +0200 Subject: [PATCH 038/138] docs(launcher): close Campaign LA4 LA5 and LA7 --- CLAUDE.md | 10 ++++++---- docs/plans/2026-04-11-roadmap.md | 11 ++++++----- docs/plans/2026-08-14-launcher-campaign.md | 6 +++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec636c8b..ba9033fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,11 +245,13 @@ NO 3D preview (chargen-only machinery); UI Studio no longer exists (deleted at Campaign V — ignore stale memory/docs claims otherwise); App `Program.cs` has no subcommand dispatch (the `--session-config` flag is additive). -LA0, LA1, LA2, LA3, and LA7a are review-closed. The launcher composer is now +LA0 through LA5 plus LA7 are review-closed. The launcher composer is now compiled into both host test suites, and Launcher.Core runs in the portable -Windows/Ubuntu CI closure. Probe mode and the canonical idle policy are merged; -LA4 Avalonia, LA5 plugin hosting, and LA7b selection state/flow are the active -parallel wave. +Windows/Ubuntu CI closure. The self-contained Avalonia launcher, +transactional two-host plugin lifetime, and Runtime-owned retail selection +state/flow are integrated; the combined Release gate passes 13,769 tests / 4 +skips. LA6 login commands, LA8's authored retail screen, and LA9 installer are +the active parallel wave; LA10 updater and LA11 closeout follow. **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index f05cf8ac..0c3c68eb 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -103,13 +103,14 @@ a future campaign). Spec: [`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); plan + ledger: [`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). -LA0, LA1, LA2, LA3, and LA7a are review-closed: the portable path boundary, +LA0 through LA5 plus LA7 are review-closed: the portable path boundary, failure-isolated launch/status contract, BCL-only launcher core, shared composer-to-both-host-loader anti-drift gate, and character wire messages are -landed. Probe mode now terminates before selection/world entry only after a -roster has been reported, and the idle policy preserves the canonical host -lifecycle. LA4 Avalonia, LA5 plugin hosting, and LA7b selection state/flow are -the active parallel wave. +landed. The self-contained Avalonia launcher, transactional two-host plugin +lifetime, and Runtime-owned retail selection state/flow are integrated. The +combined Release gate passes 13,769 tests / 4 skips. LA6 login commands, LA8's +authored retail screen, and LA9 installer are the active parallel wave; LA10 +updater and LA11 connected/visual closeout follow. **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 0de911ff..dfb81ae6 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -496,10 +496,10 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. | | LA2 | **DONE + MERGED 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0`, merge `e01b2cd1` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Branch gates: Runtime 1,632/1,632 and Headless 149/149 on both Windows and Ubuntu/WSL. Integrated gates: Release solution build green; Windows Runtime 1,636/1,636, Headless 151/151, App 5,039+3 skip, Launcher.Core 114/114; WSL Runtime 1,636/1,636, Headless 151/151, Launcher.Core 114/114. Repeated live ACE probe remains the LA11 user gate. | | LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. | -| LA4 | — | | | | -| LA5 | — | | | | +| LA4 | **DONE + MERGED 2026-08-14** | `d0a9c65d`, `10a712d6`, `ae2cbbee`, merge `60f62799` | Initial dual-lens review found 10 issues; fix re-review left one Linux execute-bit gap; final narrow re-review PASS | Avalonia 12.1.1 launcher remains thin over one BCL-only Core orchestrator. Windows/WSL Launcher.Core 162/162 and Launcher 17/17. Native `linux-x64` publish evaluates self-contained + single-file, runs without a discoverable runtime, and CI verifies executable launcher/App/Headless artifacts. LA9/LA10 bodies and LA11 visual/accessibility confirmation remain intentionally later. | +| LA5 | **DONE + MERGED 2026-08-14** | `95f4be94`, `fbe9c8a2`, `f820eb25`, merge `5535d0ad` | Initial review found 5 issues; first narrow re-review found 4 ownership/race gaps; final narrow re-review PASS | Both hosts share exact absent/null=`all`, `[]`=`none` allow-listing; transactional scoped UI/entity/selection rollback precedes unload; graphical/headless status and teardown ordering match; headless replay is exact-once under Runtime's borrowed membership lease. Branch complete suite 13,679+4 skip; portable WSL closure green. | | LA6 | — | | | | -| LA7 | **LA7a DONE + MERGED** (`fa2de1c4`); LA7b (state+flow) unblocked by LA1 | `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4` | Opus retail-lens PASS; narrow re-review MERGE; AD-97 filed | Review decoded the retail binary: restore is ≥16 bytes, guid-only is an ADAPTATION (AD-97); conditional 0xF643 parse CONFIRMED; enum corrects ACE's 0x08 misnaming. Core.Net 953/0/0 post-merge | +| LA7 | **DONE + MERGED 2026-08-14** | LA7a `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4`; LA7b `0e82cbf7`, `1b9e7e41`, `ff406562`, merge `7691cf75` | LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed | Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; `NumErrors` is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green. | | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | From ff6ebb6a6a8eaf75323578b8860489f9905a4a9c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 20:06:37 +0200 Subject: [PATCH 039/138] feat(launcher): add verified first-run installer --- .github/workflows/headless-portability.yml | 8 +- docs/architecture/acdream-architecture.md | 7 +- src/AcDream.Bake/BakeCommandLine.cs | 125 +++++ src/AcDream.Bake/BakeProgressJsonWriter.cs | 101 ++++ src/AcDream.Bake/BakeProgressReporter.cs | 34 ++ src/AcDream.Bake/BakeRunner.cs | 35 +- src/AcDream.Bake/Program.cs | 85 +--- .../Installation/BakeProcessRunner.cs | 172 +++++++ .../Installation/BakeProgressEvent.cs | 54 ++ .../Installation/BakeProgressJsonlParser.cs | 230 +++++++++ .../Installation/DatDirectoryLocator.cs | 138 ++++++ .../LauncherInstallRecordStore.cs | 337 +++++++++++++ .../Installation/LauncherInstaller.cs | 466 ++++++++++++++++++ .../Integrity/FileIntegrity.cs | 9 +- .../Launching/LauncherInstallRecord.cs | 22 +- .../Orchestration/LauncherOrchestrator.cs | 15 +- src/AcDream.Launcher/App.axaml.cs | 45 +- src/AcDream.Launcher/MainWindow.axaml | 97 +++- src/AcDream.Launcher/MainWindow.axaml.cs | 33 +- .../ViewModels/FirstRunInstallerViewModel.cs | 384 +++++++++++++++ .../ViewModels/LauncherWindowViewModel.cs | 34 +- .../BakeProgressCliTests.cs | 105 ++++ .../BakeProgressJsonlParserTests.cs | 57 +++ .../Installation/DatDirectoryLocatorTests.cs | 90 ++++ .../LauncherInstallRecordStoreTests.cs | 178 +++++++ .../Installation/LauncherInstallerTests.cs | 336 +++++++++++++ .../LauncherProjectBoundaryTests.cs | 3 + .../LauncherWindowViewModelTests.cs | 184 ++++++- 28 files changed, 3259 insertions(+), 125 deletions(-) create mode 100644 src/AcDream.Bake/BakeCommandLine.cs create mode 100644 src/AcDream.Bake/BakeProgressJsonWriter.cs create mode 100644 src/AcDream.Bake/BakeProgressReporter.cs create mode 100644 src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs create mode 100644 src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs create mode 100644 src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs create mode 100644 src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs create mode 100644 src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs create mode 100644 src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs create mode 100644 src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs create mode 100644 tests/AcDream.Bake.Tests/BakeProgressCliTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressJsonlParserTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/DatDirectoryLocatorTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 801f3476..02cc292c 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -8,6 +8,7 @@ on: - "src/AcDream.Platform/**" - "src/AcDream.Launcher.Core/**" - "src/AcDream.Launcher/**" + - "src/AcDream.Bake/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -19,6 +20,7 @@ on: - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Launcher.Core.Tests/**" - "tests/AcDream.Launcher.Tests/**" + - "tests/AcDream.Bake.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -36,6 +38,7 @@ on: - "src/AcDream.Platform/**" - "src/AcDream.Launcher.Core/**" - "src/AcDream.Launcher/**" + - "src/AcDream.Bake/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -47,6 +50,7 @@ on: - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Launcher.Core.Tests/**" - "tests/AcDream.Launcher.Tests/**" + - "tests/AcDream.Bake.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -80,7 +84,7 @@ jobs: dotnet-version: "10.0.x" # No apt step here on purpose. This job's whole claim is that the closure - # below is presentation-free: it builds Plugin.Abstractions, Core, + # below is presentation-free: it builds Bake, Plugin.Abstractions, Core, # Core.Net, Content, Runtime and Headless, runs their tests, and invokes # the Headless CLI. Nothing in it opens a display, links GL, or calls # xvfb-run, so an "install the graphical smoke dependencies" step here was @@ -94,6 +98,7 @@ jobs: $projects = @( "src/AcDream.Platform/AcDream.Platform.csproj", "src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj", + "src/AcDream.Bake/AcDream.Bake.csproj", "src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj", "src/AcDream.Core/AcDream.Core.csproj", "src/AcDream.Core.Net/AcDream.Core.Net.csproj", @@ -116,6 +121,7 @@ jobs: $projects = @( "tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj", "tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj", + "tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj", "tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj", "tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj", "tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj", diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 37753218..2b22c7dd 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -290,10 +290,15 @@ src/ Status/ -> incremental host-status parsing/tailing Orchestration/ -> immutable UI snapshots, typed actions, capability gates, and running-session lifetime + Installation/ -> portable four-DAT validation, Windows retail + path discovery, versioned JSONL bake-process + orchestration, and atomic SHA/size/tool-version + install-record verification and recovery -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell - ViewModels/ -> thin MVVM projection over Launcher.Core + ViewModels/ -> thin MVVM projection over Launcher.Core, + including the first-run DAT/bake wizard -> references Launcher.Core only (Platform transitively); it never owns a second profile, process, status, or credential state graph -> Linux launcher/probe/headless flows remain portable; graphical-client diff --git a/src/AcDream.Bake/BakeCommandLine.cs b/src/AcDream.Bake/BakeCommandLine.cs new file mode 100644 index 00000000..e5c76139 --- /dev/null +++ b/src/AcDream.Bake/BakeCommandLine.cs @@ -0,0 +1,125 @@ +using System.Globalization; + +namespace AcDream.Bake; + +internal sealed record BakeCommandLineOptions( + string DatDirectory, + string OutputPath, + HashSet? IdFilter, + HashSet? LandblockFilter, + int Threads, + bool ProgressJson); + +internal static class BakeCommandLine +{ + internal const string Usage = + "usage: acdream-bake --dat-dir [--out ] " + + "[--ids 0xId,0xId,...] [--landblocks 0xId,...] " + + "[--threads ] [--progress-json]"; + + public static bool TryParse( + IReadOnlyList args, + TextWriter error, + out BakeCommandLineOptions? options) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(error); + + string? datDirectory = null; + string? outputPath = null; + HashSet? idFilter = null; + HashSet? landblockFilter = null; + int threads = Environment.ProcessorCount; + bool progressJson = false; + + for (int i = 0; i < args.Count; i++) + { + switch (args[i]) + { + case "--dat-dir": + datDirectory = Value(args, ref i); + break; + case "--out": + outputPath = Value(args, ref i); + break; + case "--ids": + idFilter = ParseHexList(Value(args, ref i), error); + break; + case "--landblocks": + landblockFilter = ParseHexList(Value(args, ref i), error); + break; + case "--threads": + if (int.TryParse( + Value(args, ref i), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int parsedThreads) + && parsedThreads > 0) + { + threads = parsedThreads; + } + break; + case "--progress-json": + progressJson = true; + break; + default: + error.WriteLine($"unrecognized argument: {args[i]}"); + options = null; + return false; + } + } + + if (string.IsNullOrWhiteSpace(datDirectory)) + { + error.WriteLine(Usage); + options = null; + return false; + } + + outputPath ??= Path.Combine(datDirectory, "acdream.pak"); + options = new BakeCommandLineOptions( + datDirectory, + outputPath, + idFilter, + landblockFilter, + threads, + progressJson); + return true; + } + + private static string? Value(IReadOnlyList args, ref int index) => + index + 1 < args.Count ? args[++index] : null; + + private static HashSet ParseHexList(string? raw, TextWriter error) + { + var result = new HashSet(); + if (string.IsNullOrWhiteSpace(raw)) + { + return result; + } + + foreach (string token in raw.Split( + ',', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries)) + { + string hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? token[2..] + : token; + if (uint.TryParse( + hex, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out uint value)) + { + result.Add(value); + } + else + { + error.WriteLine($"warning: could not parse id '{token}' - skipped"); + } + } + + return result; + } +} diff --git a/src/AcDream.Bake/BakeProgressJsonWriter.cs b/src/AcDream.Bake/BakeProgressJsonWriter.cs new file mode 100644 index 00000000..8dd7ccfa --- /dev/null +++ b/src/AcDream.Bake/BakeProgressJsonWriter.cs @@ -0,0 +1,101 @@ +using System.Text.Json; + +namespace AcDream.Bake; + +public interface IBakeProgressSink +{ + void Started(uint bakeToolVersion, string outputPath); + + void Progress( + string phase, + long completed, + long total, + int failures, + double elapsedSeconds, + double etaSeconds, + long privateBytes, + long managedBytes); + + void Completed(uint bakeToolVersion, long outputBytes, int failures); + + void Error(string message); +} + +/// +/// Version-1 JSON-lines machine channel enabled only by +/// --progress-json. Ordinary human console lines remain unchanged and +/// share stdout; consumers identify these records by shape instead of +/// scraping human prose. +/// +public sealed class BakeProgressJsonWriter(TextWriter output) : IBakeProgressSink +{ + public const int CurrentVersion = 1; + + private readonly TextWriter _output = output + ?? throw new ArgumentNullException(nameof(output)); + private readonly object _gate = new(); + + public void Started(uint bakeToolVersion, string outputPath) => + Write(new + { + v = CurrentVersion, + e = "started", + t = DateTimeOffset.UtcNow, + bakeToolVersion, + outputPath, + }); + + public void Progress( + string phase, + long completed, + long total, + int failures, + double elapsedSeconds, + double etaSeconds, + long privateBytes, + long managedBytes) => + Write(new + { + v = CurrentVersion, + e = "progress", + t = DateTimeOffset.UtcNow, + phase, + completed, + total, + failures, + elapsedSeconds, + etaSeconds, + privateBytes, + managedBytes, + }); + + public void Completed(uint bakeToolVersion, long outputBytes, int failures) => + Write(new + { + v = CurrentVersion, + e = "completed", + t = DateTimeOffset.UtcNow, + bakeToolVersion, + outputBytes, + failures, + }); + + public void Error(string message) => + Write(new + { + v = CurrentVersion, + e = "error", + t = DateTimeOffset.UtcNow, + message, + }); + + private void Write(T value) + { + string line = JsonSerializer.Serialize(value); + lock (_gate) + { + _output.WriteLine(line); + _output.Flush(); + } + } +} diff --git a/src/AcDream.Bake/BakeProgressReporter.cs b/src/AcDream.Bake/BakeProgressReporter.cs new file mode 100644 index 00000000..954fddac --- /dev/null +++ b/src/AcDream.Bake/BakeProgressReporter.cs @@ -0,0 +1,34 @@ +namespace AcDream.Bake; + +internal static class BakeProgressReporter +{ + public static void Write( + TextWriter humanOutput, + IBakeProgressSink? machineOutput, + string phase, + long completed, + int total, + int failures, + TimeSpan elapsed, + double etaSeconds, + long privateBytes, + long managedBytes) + { + ArgumentNullException.ThrowIfNull(humanOutput); + humanOutput.WriteLine( + $"[{elapsed:hh\\:mm\\:ss}] extracted {completed:N0}/{total:N0}, " + + $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " + + $"ETA={etaSeconds:F0}s, " + + $"private={privateBytes / 1024.0 / 1024.0:F0}MB, " + + $"managed={managedBytes / 1024.0 / 1024.0:F0}MB"); + machineOutput?.Progress( + phase, + completed, + total, + failures, + elapsed.TotalSeconds, + etaSeconds, + privateBytes, + managedBytes); + } +} diff --git a/src/AcDream.Bake/BakeRunner.cs b/src/AcDream.Bake/BakeRunner.cs index 9ea6f1bb..6b60a2ea 100644 --- a/src/AcDream.Bake/BakeRunner.cs +++ b/src/AcDream.Bake/BakeRunner.cs @@ -22,6 +22,7 @@ public sealed record BakeOptions public HashSet? LandblockFilter { get; init; } public int Threads { get; init; } = System.Environment.ProcessorCount; public CancellationToken CancellationToken { get; init; } + public IBakeProgressSink? Progress { get; init; } } /// Compact result used by the full-scale gate and deterministic tests. @@ -89,6 +90,7 @@ public static class BakeRunner throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive"); options.CancellationToken.ThrowIfCancellationRequested(); + options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath); var totalStopwatch = Stopwatch.StartNew(); var report = BakeOutputTransaction.WriteValidateAndPublish( options.OutPath, @@ -112,6 +114,10 @@ public static class BakeRunner }; PrintSummary(report, options.OutPath); + options.Progress?.Completed( + report.Header.BakeToolVersion, + report.OutputBytes, + report.Failures); return report; } @@ -273,6 +279,8 @@ public static class BakeRunner failures.Count, stopwatch.Elapsed, lastProgressReport, + options.Progress, + "mesh", batchStart + BatchSize >= ordinaryWork.Count && envCatalog.UniqueGeometryCount == 0); } @@ -372,6 +380,8 @@ public static class BakeRunner failures.Count, stopwatch.Elapsed, lastProgressReport, + options.Progress, + "mesh", batchStart + BatchSize >= envCatalog.Groups.Count); } @@ -571,6 +581,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: false); } @@ -757,6 +769,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: false); } } @@ -769,6 +783,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: true); writer.Finish(); @@ -910,6 +926,8 @@ public static class BakeRunner int failures, TimeSpan elapsed, Stopwatch lastProgressReport, + IBakeProgressSink? progress, + string phase, bool final) { if (!final && lastProgressReport.Elapsed.TotalSeconds < 5) @@ -920,11 +938,18 @@ public static class BakeRunner using var process = Process.GetCurrentProcess(); process.Refresh(); long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes; - Console.WriteLine( - $"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " + - $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " + - $"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " + - $"managed={managedHeap / 1024.0 / 1024.0:F0}MB"); + long privateBytes = process.PrivateMemorySize64; + BakeProgressReporter.Write( + Console.Out, + progress, + phase, + done, + total, + failures, + elapsed, + etaSeconds, + privateBytes, + managedHeap); lastProgressReport.Restart(); } diff --git a/src/AcDream.Bake/Program.cs b/src/AcDream.Bake/Program.cs index 2d7ee8c0..4abfc862 100644 --- a/src/AcDream.Bake/Program.cs +++ b/src/AcDream.Bake/Program.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using AcDream.Bake; // acdream-bake: offline CLI producing a versioned pak file containing every @@ -13,66 +9,35 @@ using AcDream.Bake; // // Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5. -string? datDir = null; -string? outPath = null; -HashSet? idFilter = null; -HashSet? landblockFilter = null; -int threads = Environment.ProcessorCount; - -for (int i = 0; i < args.Length; i++) { - switch (args[i]) { - case "--dat-dir": - datDir = args.ElementAtOrDefault(++i); - break; - case "--out": - outPath = args.ElementAtOrDefault(++i); - break; - case "--ids": - idFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--landblocks": - landblockFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--threads": - if (int.TryParse(args.ElementAtOrDefault(++i), out var t) && t > 0) threads = t; - break; - default: - Console.Error.WriteLine($"unrecognized argument: {args[i]}"); - return 2; - } -} - -if (string.IsNullOrWhiteSpace(datDir)) { - Console.Error.WriteLine("usage: acdream-bake --dat-dir [--out ] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads ]"); +if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command)) +{ return 2; } -if (!Directory.Exists(datDir)) { - Console.Error.WriteLine($"error: directory not found: {datDir}"); +if (!Directory.Exists(command!.DatDirectory)) +{ + Console.Error.WriteLine($"error: directory not found: {command.DatDirectory}"); return 2; } -outPath ??= Path.Combine(datDir, "acdream.pak"); - -return BakeRunner.Run(new BakeOptions { - DatDir = datDir, - OutPath = outPath, - IdFilter = idFilter, - LandblockFilter = landblockFilter, - Threads = threads, -}); - -static HashSet ParseHexList(string? raw) { - var result = new HashSet(); - if (string.IsNullOrWhiteSpace(raw)) return result; - foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - var hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token; - if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var value)) { - result.Add(value); - } - else { - Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped"); - } - } - return result; +IBakeProgressSink? progress = command.ProgressJson + ? new BakeProgressJsonWriter(Console.Out) + : null; +try +{ + return BakeRunner.Run(new BakeOptions + { + DatDir = command.DatDirectory, + OutPath = command.OutputPath, + IdFilter = command.IdFilter, + LandblockFilter = command.LandblockFilter, + Threads = command.Threads, + Progress = progress, + }); +} +catch (Exception exception) +{ + progress?.Error(exception.Message); + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; } diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs new file mode 100644 index 00000000..7c11ea15 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -0,0 +1,172 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; + +namespace AcDream.Launcher.Core.Installation; + +/// The exact child-process contract for one launcher bake. +public sealed record BakeProcessRequest( + string ExecutablePath, + string DatDirectory, + string OutputPath, + int Threads) +{ + public IReadOnlyList Arguments => + [ + "--dat-dir", + DatDirectory, + "--out", + OutputPath, + "--threads", + Threads.ToString(CultureInfo.InvariantCulture), + "--progress-json", + ]; +} + +public sealed record BakeProcessResult(int ExitCode, string StandardError); + +/// +/// Injectable child seam. Stdout is delivered as arbitrary chunks so the +/// versioned JSONL parser, rather than line-oriented process plumbing, owns +/// partial-record behavior. +/// +public interface IBakeProcessRunner +{ + Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default); +} + +public sealed class SystemBakeProcessRunner : IBakeProcessRunner +{ + private const int BufferSize = 4096; + private const int MaximumCapturedErrorCharacters = 32 * 1024; + + public async Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(onStandardOutput); + if (request.Threads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(request), + "Bake thread count must be positive."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var startInfo = new ProcessStartInfo + { + FileName = request.ExecutablePath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in request.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + throw new InvalidOperationException("The bake process could not be started."); + } + + // The bake consumes no credential or other stdin input. + process.StandardInput.Close(); + + var standardError = new StringBuilder(); + Task stdoutPump = PumpAsync( + process.StandardOutput, + onStandardOutput, + CancellationToken.None); + Task stderrPump = PumpAsync( + process.StandardError, + chunk => AppendBounded(standardError, chunk), + CancellationToken.None); + + using CancellationTokenRegistration cancellation = cancellationToken.Register( + static state => + { + var child = (Process)state!; + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + } + catch + { + // The cancellation token remains authoritative. Races with + // natural exit or handle teardown do not replace it. + } + }, + process); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(stdoutPump, stderrPump).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new BakeProcessResult(process.ExitCode, standardError.ToString()); + } + catch (OperationCanceledException) + { + try + { + using var cleanupTimeout = new CancellationTokenSource( + TimeSpan.FromSeconds(5)); + await process.WaitForExitAsync(cleanupTimeout.Token) + .ConfigureAwait(false); + await Task.WhenAll(stdoutPump, stderrPump) + .WaitAsync(cleanupTimeout.Token) + .ConfigureAwait(false); + } + catch + { + // Preserve cancellation. The process kill registration above + // already made the best effort to terminate the tree. + } + + throw; + } + } + + private static async Task PumpAsync( + TextReader reader, + Action sink, + CancellationToken cancellationToken) + { + char[] buffer = new char[BufferSize]; + while (true) + { + int read = await reader.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return; + } + + sink(new string(buffer, 0, read)); + } + } + + private static void AppendBounded(StringBuilder destination, string chunk) + { + int remaining = MaximumCapturedErrorCharacters - destination.Length; + if (remaining <= 0) + { + return; + } + + destination.Append(chunk.AsSpan(0, Math.Min(remaining, chunk.Length))); + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs new file mode 100644 index 00000000..608c602e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs @@ -0,0 +1,54 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Versioned machine-readable output from acdream-bake +/// --progress-json. Human output shares stdout but remains a distinct +/// event so the installer never derives state by scraping prose. +/// +public abstract record BakeProgressEvent(int Version, string EventName); + +public sealed record BakeStartedEvent( + int Version, + uint BakeToolVersion, + string? OutputPath) + : BakeProgressEvent(Version, "started"); + +public sealed record BakeWorkProgressEvent( + int Version, + string Phase, + long Completed, + long Total, + int Failures, + double ElapsedSeconds, + double EtaSeconds) + : BakeProgressEvent(Version, "progress"); + +public sealed record BakeCompletedEvent( + int Version, + uint BakeToolVersion, + long OutputBytes, + int Failures) + : BakeProgressEvent(Version, "completed"); + +public sealed record BakeErrorEvent(int Version, string Message) + : BakeProgressEvent(Version, "error"); + +public sealed record UnknownBakeProgressEvent( + int Version, + string EventName, + string RawLine) + : BakeProgressEvent(Version, EventName); + +public sealed record FutureBakeProgressEvent( + int Version, + string EventName, + string RawLine) + : BakeProgressEvent(Version, EventName); + +public sealed record MalformedBakeProgressEvent( + string RawLine, + string Reason) + : BakeProgressEvent(0, "malformed"); + +public sealed record BakeHumanOutputEvent(string Text) + : BakeProgressEvent(0, "human"); diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs new file mode 100644 index 00000000..cc4cafb5 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs @@ -0,0 +1,230 @@ +using System.Text; +using System.Text.Json; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// Incremental JSONL parser tolerant of arbitrary stream chunk boundaries. +/// Unknown event names and future protocol versions stay observable without +/// failing the bake; malformed known payloads are explicit typed events. +/// +public sealed class BakeProgressJsonlParser +{ + public const int CurrentVersion = 1; + + private readonly StringBuilder _pending = new(); + + public IReadOnlyList Append(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + _pending.Append(chunk); + return Drain(completeFinalLine: false); + } + + public IReadOnlyList Complete() => + Drain(completeFinalLine: true); + + public static BakeProgressEvent ParseLine(string line) + { + ArgumentNullException.ThrowIfNull(line); + string trimmed = line.Trim(); + if (trimmed.Length == 0) + { + return new BakeHumanOutputEvent(string.Empty); + } + + if (trimmed[0] != '{') + { + return new BakeHumanOutputEvent(line.TrimEnd('\r')); + } + + try + { + using JsonDocument document = JsonDocument.Parse(trimmed); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !TryGetInt32(root, "v", out int version) + || !TryGetString(root, "e", out string? eventName)) + { + return Malformed(line, "JSON progress requires integer 'v' and string 'e'."); + } + + if (version != CurrentVersion) + { + return new FutureBakeProgressEvent(version, eventName!, line); + } + + return eventName switch + { + "started" => ParseStarted(root, version, line), + "progress" => ParseProgress(root, version, line), + "completed" => ParseCompleted(root, version, line), + "error" => ParseError(root, version, line), + _ => new UnknownBakeProgressEvent(version, eventName!, line), + }; + } + catch (JsonException ex) + { + return Malformed(line, ex.Message); + } + } + + private IReadOnlyList Drain(bool completeFinalLine) + { + var events = new List(); + while (true) + { + int newline = IndexOfNewline(_pending); + if (newline < 0) + { + break; + } + + string line = _pending.ToString(0, newline); + _pending.Remove(0, newline + 1); + events.Add(ParseLine(line)); + } + + if (completeFinalLine && _pending.Length > 0) + { + string line = _pending.ToString(); + _pending.Clear(); + events.Add(ParseLine(line)); + } + + return events; + } + + private static int IndexOfNewline(StringBuilder value) + { + for (int i = 0; i < value.Length; i++) + { + if (value[i] == '\n') + { + return i; + } + } + + return -1; + } + + private static BakeProgressEvent ParseStarted( + JsonElement root, + int version, + string raw) + { + if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion) + || bakeToolVersion == 0) + { + return Malformed(raw, "started requires a positive bakeToolVersion."); + } + + _ = TryGetString(root, "outputPath", out string? outputPath); + return new BakeStartedEvent(version, bakeToolVersion, outputPath); + } + + private static BakeProgressEvent ParseProgress( + JsonElement root, + int version, + string raw) + { + if (!TryGetString(root, "phase", out string? phase) + || !TryGetInt64(root, "completed", out long completed) + || !TryGetInt64(root, "total", out long total) + || !TryGetInt32(root, "failures", out int failures) + || !TryGetDouble(root, "elapsedSeconds", out double elapsedSeconds) + || !TryGetDouble(root, "etaSeconds", out double etaSeconds) + || completed < 0 + || total < 0 + || completed > total + || failures < 0 + || elapsedSeconds < 0 + || etaSeconds < 0) + { + return Malformed(raw, "progress payload has missing or invalid fields."); + } + + return new BakeWorkProgressEvent( + version, + phase!, + completed, + total, + failures, + elapsedSeconds, + etaSeconds); + } + + private static BakeProgressEvent ParseCompleted( + JsonElement root, + int version, + string raw) + { + if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion) + || !TryGetInt64(root, "outputBytes", out long outputBytes) + || !TryGetInt32(root, "failures", out int failures) + || bakeToolVersion == 0 + || outputBytes <= 0 + || failures < 0) + { + return Malformed(raw, "completed payload has missing or invalid fields."); + } + + return new BakeCompletedEvent( + version, + bakeToolVersion, + outputBytes, + failures); + } + + private static BakeProgressEvent ParseError( + JsonElement root, + int version, + string raw) => + TryGetString(root, "message", out string? message) + && !string.IsNullOrWhiteSpace(message) + ? new BakeErrorEvent(version, message) + : Malformed(raw, "error requires a non-empty message."); + + private static MalformedBakeProgressEvent Malformed(string raw, string reason) => + new(raw, reason); + + private static bool TryGetString( + JsonElement root, + string name, + out string? value) + { + value = null; + return root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + && (value = element.GetString()) is not null; + } + + private static bool TryGetInt32(JsonElement root, string name, out int value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetInt32(out value); + } + + private static bool TryGetUInt32(JsonElement root, string name, out uint value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetUInt32(out value); + } + + private static bool TryGetInt64(JsonElement root, string name, out long value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetInt64(out value); + } + + private static bool TryGetDouble(JsonElement root, string name, out double value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetDouble(out value) + && double.IsFinite(value); + } +} diff --git a/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs new file mode 100644 index 00000000..72096a4f --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs @@ -0,0 +1,138 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Portable validation and Windows-only discovery for the four retail data +/// archives consumed by acdream-bake. Discovery is intentionally only +/// a list of conventional paths; validation is the same filesystem operation +/// on Windows and Linux, including for a manually entered path. +/// +public sealed class DatDirectoryLocator +{ + public static IReadOnlyList RequiredFileNames { get; } = + Array.AsReadOnly( + [ + "client_portal.dat", + "client_cell_1.dat", + "client_highres.dat", + "client_local_English.dat", + ]); + + private readonly bool _isWindows; + private readonly string[] _windowsCandidates; + private readonly Func _directoryExists; + private readonly Func _fileExists; + + public DatDirectoryLocator( + bool? isWindows = null, + IEnumerable? windowsCandidates = null, + Func? directoryExists = null, + Func? fileExists = null) + { + _isWindows = isWindows ?? OperatingSystem.IsWindows(); + _windowsCandidates = (windowsCandidates ?? DefaultWindowsCandidates()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + _directoryExists = directoryExists ?? Directory.Exists; + _fileExists = fileExists ?? File.Exists; + } + + /// + /// Returns conventional Windows locations that actually exist, in + /// preference order. An existing but incomplete directory remains in the + /// result so the wizard can explain exactly which DATs are missing. + /// Linux returns an empty list and relies on the manual picker/path field. + /// + public IReadOnlyList Detect() + { + if (!_isWindows) + { + return []; + } + + return _windowsCandidates + .Where(_directoryExists) + .Select(Validate) + .ToArray(); + } + + public DatDirectoryValidation Validate(string? directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return DatDirectoryValidation.Invalid( + directory ?? string.Empty, + "Choose the folder containing the retail DAT files.", + RequiredFileNames); + } + + string fullPath; + try + { + fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return DatDirectoryValidation.Invalid( + directory, + "The DAT directory path is not valid.", + RequiredFileNames); + } + + if (!_directoryExists(fullPath)) + { + return DatDirectoryValidation.Invalid( + fullPath, + "The DAT directory does not exist.", + RequiredFileNames); + } + + string[] missing = RequiredFileNames + .Where(fileName => !_fileExists(Path.Combine(fullPath, fileName))) + .ToArray(); + return missing.Length == 0 + ? DatDirectoryValidation.Valid(fullPath) + : DatDirectoryValidation.Invalid( + fullPath, + "The selected directory is missing required retail DAT files.", + missing); + } + + private static IEnumerable DefaultWindowsCandidates() + { + string userProfile = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(userProfile)) + { + yield return Path.Combine( + userProfile, + "Documents", + "Asheron's Call"); + } + + yield return @"C:\Turbine\Asheron's Call"; + } +} + +public sealed record DatDirectoryValidation( + string Directory, + bool IsValid, + string Message, + IReadOnlyList MissingFileNames) +{ + internal static DatDirectoryValidation Valid(string directory) => + new( + directory, + true, + "All four required retail DAT files were found.", + []); + + internal static DatDirectoryValidation Invalid( + string directory, + string message, + IReadOnlyList missingFileNames) => + new(directory, false, message, missingFileNames); +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs new file mode 100644 index 00000000..c6815d1d --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -0,0 +1,337 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Launching; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +public enum InstallRecordVerificationState +{ + Missing, + Verified, + Invalid, +} + +public sealed record InstallRecordVerification( + InstallRecordVerificationState State, + LauncherInstallRecord? Record, + string Status) +{ + public bool IsVerified => State == InstallRecordVerificationState.Verified; +} + +/// +/// Versioned install-record persistence and startup verification. The record +/// is atomically replaced only after a complete package has been hashed; a +/// crash during a reinstall can recover the prior verified pak from the +/// adjacent backup before launch is enabled. +/// +public sealed class LauncherInstallRecordStore +{ + public const uint CurrentBakeToolVersion = 4; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + private readonly ApplicationPathSet _paths; + private readonly DatDirectoryLocator _datDirectories; + private readonly Func> _computeSha256; + + public LauncherInstallRecordStore( + ApplicationPathSet paths, + DatDirectoryLocator? datDirectories = null, + Func>? computeSha256 = null) + { + _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + _datDirectories = datDirectories ?? new DatDirectoryLocator(); + _computeSha256 = computeSha256 + ?? ((path, cancellationToken) => + FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + } + + public string RecordPath => Path.Combine(_paths.DataDirectory, "install.json"); + + public string PreparedAssetPath => Path.Combine( + _paths.DataDirectory, + "pak", + "acdream.pak"); + + public static string GetBackupPath(string preparedAssetPath) => + preparedAssetPath + ".previous-install"; + + public async Task LoadAndVerifyAsync( + CancellationToken cancellationToken = default) + { + if (!File.Exists(RecordPath)) + { + return new InstallRecordVerification( + InstallRecordVerificationState.Missing, + null, + "Client content is not installed. Complete the first-run setup."); + } + + LauncherInstallRecord? record; + try + { + await using FileStream stream = new( + RecordPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + record = await JsonSerializer.DeserializeAsync( + stream, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException) + { + return Invalid($"The install record could not be read: {ex.Message}"); + } + + if (record is null) + { + return Invalid("The install record is empty."); + } + + string? contractError = ValidateRecordContract(record); + if (contractError is not null) + { + return Invalid(contractError); + } + + string backupPath = GetBackupPath(record.PreparedAssetPath); + FileVerification current = await VerifyFileAsync( + record.PreparedAssetPath, + record, + cancellationToken) + .ConfigureAwait(false); + if (current.IsValid) + { + TryDelete(backupPath); + return Verified(record); + } + + // A process crash may occur after the old verified package was moved + // aside but before the replacement record was published. Verify the + // backup against the still-current record before restoring it. + FileVerification backup = await VerifyFileAsync( + backupPath, + record, + cancellationToken) + .ConfigureAwait(false); + if (backup.IsValid) + { + try + { + Directory.CreateDirectory( + Path.GetDirectoryName(record.PreparedAssetPath) + ?? throw new InvalidOperationException( + "The prepared asset path has no parent directory.")); + File.Move(backupPath, record.PreparedAssetPath, overwrite: true); + return Verified(record, "Recovered and verified the previous client content."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return Invalid( + $"The previous verified package could not be restored: {ex.Message}"); + } + } + + return Invalid(current.Status); + } + + public async Task SaveAtomicallyAsync( + LauncherInstallRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + string? contractError = ValidateRecordContract(record); + if (contractError is not null) + { + throw new InvalidDataException(contractError); + } + + string directory = Path.GetDirectoryName(RecordPath) + ?? throw new InvalidOperationException( + "The install record has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(RecordPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await using (FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync( + stream, + record, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, RecordPath, overwrite: true); + } + finally + { + TryDelete(temporaryPath); + } + } + + private string? ValidateRecordContract(LauncherInstallRecord record) + { + if (record.Version != LauncherInstallRecord.CurrentRecordVersion) + { + return $"Install record version {record.Version} is not supported."; + } + + if (!record.HasIntegrityMetadata) + { + return "The install record is missing SHA-256, size, or bake-tool metadata."; + } + + if (record.BakeToolVersion != CurrentBakeToolVersion) + { + return $"Bake tool version {record.BakeToolVersion} is not supported; " + + $"version {CurrentBakeToolVersion} is required."; + } + + if (!IsSha256(record.PreparedAssetSha256)) + { + return "The install record contains an invalid SHA-256 digest."; + } + + string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath); + string recordedPreparedPath; + try + { + recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return $"The prepared asset path is invalid: {ex.Message}"; + } + + if (!PathsEqual(recordedPreparedPath, canonicalPreparedPath)) + { + return "The install record does not point to the launcher's canonical " + + "DataDirectory/pak/acdream.pak path."; + } + + DatDirectoryValidation datValidation = + _datDirectories.Validate(record.DatDirectory); + return datValidation.IsValid + ? null + : datValidation.Message + FormatMissing(datValidation.MissingFileNames); + } + + private async Task VerifyFileAsync( + string path, + LauncherInstallRecord record, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return new FileVerification(false, "The prepared package is missing."); + } + + try + { + long length = new FileInfo(path).Length; + if (length != record.PreparedAssetSize) + { + return new FileVerification( + false, + $"The prepared package size changed (expected " + + $"{record.PreparedAssetSize}, found {length})."); + } + + string sha256 = await _computeSha256(path, cancellationToken) + .ConfigureAwait(false); + return FileIntegrity.Matches(sha256, record.PreparedAssetSha256) + ? new FileVerification(true, "Client content verified.") + : new FileVerification( + false, + "The prepared package SHA-256 does not match the install record."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new FileVerification( + false, + $"The prepared package could not be verified: {ex.Message}"); + } + } + + private static InstallRecordVerification Verified( + LauncherInstallRecord record, + string status = "Client content SHA-256, size, and bake-tool version verified.") => + new(InstallRecordVerificationState.Verified, record, status); + + private static InstallRecordVerification Invalid(string status) => + new(InstallRecordVerificationState.Invalid, null, status); + + private static bool IsSha256(string value) => + value.Length == 64 && value.All(Uri.IsHexDigit); + + private static string FormatMissing(IReadOnlyList missing) => + missing.Count == 0 + ? string.Empty + : " Missing: " + string.Join(", ", missing) + "."; + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(left), + Path.TrimEndingDirectorySeparator(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + internal static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // A stale temp/backup is never treated as a published record. The + // next startup verification retries cleanup/recovery. + } + } + + private sealed record FileVerification(bool IsValid, string Status); +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs new file mode 100644 index 00000000..88a29074 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -0,0 +1,466 @@ +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Launching; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +public enum LauncherInstallPhase +{ + Idle, + ValidatingDatFiles, + PreparingOutput, + BakingMeshes, + BakingCollision, + VerifyingPackage, + SavingRecord, + Completed, + Cancelled, + Failed, +} + +public sealed record LauncherInstallProgress( + LauncherInstallPhase Phase, + string Status, + long Completed = 0, + long Total = 0, + int Failures = 0, + double EtaSeconds = 0) +{ + public double Fraction => Total > 0 + ? Math.Clamp((double)Completed / Total, 0, 1) + : 0; +} + +public sealed record LauncherInstallResult(LauncherInstallRecord Record); + +public sealed class LauncherInstallException : Exception +{ + public LauncherInstallException(string message) + : base(message) + { + } + + public LauncherInstallException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public interface ILauncherInstaller +{ + IReadOnlyList DetectDatDirectories(); + + DatDirectoryValidation ValidateDatDirectory(string? directory); + + Task LoadExistingAsync( + CancellationToken cancellationToken = default); + + Task InstallAsync( + string datDirectory, + int threads, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +/// +/// BCL-only first-run transaction. It invokes the GL-free bake executable as +/// a child, consumes only its versioned JSONL records, verifies the published +/// pak, and atomically records the install. A prior verified package is moved +/// to an adjacent recovery slot and restored on every failure/cancellation +/// path, so a fake or crashed child cannot replace it with partial output. +/// +public sealed class LauncherInstaller : ILauncherInstaller +{ + private readonly string _bakeExecutablePath; + private readonly DatDirectoryLocator _datDirectories; + private readonly LauncherInstallRecordStore _recordStore; + private readonly IBakeProcessRunner _processRunner; + private readonly Func> _computeSha256; + private readonly SemaphoreSlim _installGate = new(1, 1); + + private LauncherInstallRecord? _verifiedRecord; + + public LauncherInstaller( + ApplicationPathSet paths, + string bakeExecutablePath, + DatDirectoryLocator? datDirectories = null, + LauncherInstallRecordStore? recordStore = null, + IBakeProcessRunner? processRunner = null, + Func>? computeSha256 = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath); + _bakeExecutablePath = Path.GetFullPath(bakeExecutablePath); + _datDirectories = datDirectories ?? new DatDirectoryLocator(); + _computeSha256 = computeSha256 + ?? ((path, cancellationToken) => + FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + _recordStore = recordStore + ?? new LauncherInstallRecordStore( + paths, + _datDirectories, + _computeSha256); + _processRunner = processRunner ?? new SystemBakeProcessRunner(); + } + + public IReadOnlyList DetectDatDirectories() => + _datDirectories.Detect(); + + public DatDirectoryValidation ValidateDatDirectory(string? directory) => + _datDirectories.Validate(directory); + + public async Task LoadExistingAsync( + CancellationToken cancellationToken = default) + { + InstallRecordVerification verification = await _recordStore + .LoadAndVerifyAsync(cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = verification.Record; + return verification; + } + + public async Task InstallAsync( + string datDirectory, + int threads, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (threads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(threads), + "Bake thread count must be positive."); + } + + await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await InstallCoreAsync( + datDirectory, + threads, + progress, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + _installGate.Release(); + } + } + + private async Task InstallCoreAsync( + string datDirectory, + int threads, + IProgress? progress, + CancellationToken cancellationToken) + { + Report( + progress, + LauncherInstallPhase.ValidatingDatFiles, + "Validating the four retail DAT files..."); + DatDirectoryValidation validation = _datDirectories.Validate(datDirectory); + if (!validation.IsValid) + { + string message = validation.Message + + FormatMissing(validation.MissingFileNames); + Report(progress, LauncherInstallPhase.Failed, message); + throw new LauncherInstallException(message); + } + + if (!File.Exists(_bakeExecutablePath)) + { + string message = + $"The co-deployed bake tool is missing at '{_bakeExecutablePath}'."; + Report(progress, LauncherInstallPhase.Failed, message); + throw new LauncherInstallException(message); + } + + string outputPath = _recordStore.PreparedAssetPath; + string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); + if (_verifiedRecord is null) + { + InstallRecordVerification existing = await _recordStore + .LoadAndVerifyAsync(cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = existing.Record; + } + + Directory.CreateDirectory( + Path.GetDirectoryName(outputPath) + ?? throw new InvalidOperationException( + "The prepared package path has no parent directory.")); + + Report( + progress, + LauncherInstallPhase.PreparingOutput, + "Preparing the atomic package transaction..."); + bool previousPreserved = PreservePreviousPackage(outputPath, backupPath); + if (!previousPreserved) + { + LauncherInstallRecordStore.TryDelete(backupPath); + } + + var parser = new BakeProgressJsonlParser(); + BakeStartedEvent? started = null; + BakeCompletedEvent? completed = null; + string? protocolError = null; + string? childError = null; + + void Observe(BakeProgressEvent progressEvent) + { + switch (progressEvent) + { + case BakeStartedEvent value: + started = value; + break; + case BakeWorkProgressEvent value: + LauncherInstallPhase phase = value.Phase switch + { + "mesh" => LauncherInstallPhase.BakingMeshes, + "collision" => LauncherInstallPhase.BakingCollision, + _ => LauncherInstallPhase.BakingMeshes, + }; + Report( + progress, + phase, + $"Baking {value.Phase} assets: " + + $"{value.Completed:N0}/{value.Total:N0}; " + + $"failures: {value.Failures:N0}", + value.Completed, + value.Total, + value.Failures, + value.EtaSeconds); + break; + case BakeCompletedEvent value: + completed = value; + break; + case BakeErrorEvent value: + childError = value.Message; + Report( + progress, + LauncherInstallPhase.Failed, + $"Bake tool error: {value.Message}"); + break; + case MalformedBakeProgressEvent value: + protocolError ??= value.Reason; + Report( + progress, + LauncherInstallPhase.Failed, + $"Malformed bake progress: {value.Reason}"); + break; + // Human lines are deliberately ignored, and unknown event + // kinds are forward-compatible. A future protocol version + // cannot satisfy the required v1 started/completed pair. + } + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + var request = new BakeProcessRequest( + _bakeExecutablePath, + validation.Directory, + outputPath, + threads); + BakeProcessResult processResult = await _processRunner.RunAsync( + request, + chunk => + { + foreach (BakeProgressEvent progressEvent in parser.Append(chunk)) + { + Observe(progressEvent); + } + }, + cancellationToken) + .ConfigureAwait(false); + + foreach (BakeProgressEvent progressEvent in parser.Complete()) + { + Observe(progressEvent); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (processResult.ExitCode != 0) + { + throw new LauncherInstallException( + BuildChildFailure( + processResult.ExitCode, + childError, + processResult.StandardError)); + } + + if (!string.IsNullOrWhiteSpace(childError)) + { + throw new LauncherInstallException( + $"The bake tool reported an error: {childError}"); + } + + if (protocolError is not null) + { + throw new LauncherInstallException( + $"The bake tool emitted malformed JSON progress: {protocolError}"); + } + + if (started is null || completed is null) + { + throw new LauncherInstallException( + "The bake tool exited without the required v1 started/completed " + + "progress records."); + } + + if (started.BakeToolVersion != completed.BakeToolVersion + || completed.BakeToolVersion + != LauncherInstallRecordStore.CurrentBakeToolVersion) + { + throw new LauncherInstallException( + $"The bake tool reported version {completed.BakeToolVersion}; " + + $"version {LauncherInstallRecordStore.CurrentBakeToolVersion} " + + "is required."); + } + + if (completed.Failures != 0) + { + throw new LauncherInstallException( + $"The bake completed with {completed.Failures:N0} failed assets."); + } + + if (!File.Exists(outputPath)) + { + throw new LauncherInstallException( + "The bake tool reported success but did not publish acdream.pak."); + } + + long size = new FileInfo(outputPath).Length; + if (size <= 0 || size != completed.OutputBytes) + { + throw new LauncherInstallException( + "The published package size does not match the bake completion record."); + } + + Report( + progress, + LauncherInstallPhase.VerifyingPackage, + "Computing the prepared package SHA-256..."); + string sha256 = await _computeSha256(outputPath, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var record = new LauncherInstallRecord( + validation.Directory, + outputPath, + sha256, + size, + completed.BakeToolVersion); + Report( + progress, + LauncherInstallPhase.SavingRecord, + "Saving the verified install record..."); + await _recordStore.SaveAtomicallyAsync(record, cancellationToken) + .ConfigureAwait(false); + + _verifiedRecord = record; + LauncherInstallRecordStore.TryDelete(backupPath); + Report( + progress, + LauncherInstallPhase.Completed, + "Client content installed and verified.", + completed: 1, + total: 1); + return new LauncherInstallResult(record); + } + catch (OperationCanceledException) + { + RestorePreviousPackage(outputPath, backupPath, previousPreserved); + Report( + progress, + LauncherInstallPhase.Cancelled, + "Installation cancelled; no new install record was published."); + throw; + } + catch (Exception ex) + { + RestorePreviousPackage(outputPath, backupPath, previousPreserved); + Report( + progress, + LauncherInstallPhase.Failed, + $"Installation failed: {ex.Message}"); + if (ex is LauncherInstallException) + { + throw; + } + + throw new LauncherInstallException("Installation failed.", ex); + } + } + + private bool PreservePreviousPackage(string outputPath, string backupPath) + { + LauncherInstallRecord? previous = _verifiedRecord; + if (previous is null + || !PathsEqual(previous.PreparedAssetPath, outputPath) + || !File.Exists(outputPath)) + { + return false; + } + + File.Move(outputPath, backupPath, overwrite: true); + return true; + } + + private static void RestorePreviousPackage( + string outputPath, + string backupPath, + bool previousPreserved) + { + if (previousPreserved && File.Exists(backupPath)) + { + File.Move(backupPath, outputPath, overwrite: true); + return; + } + + LauncherInstallRecordStore.TryDelete(outputPath); + LauncherInstallRecordStore.TryDelete(backupPath); + } + + private static string BuildChildFailure( + int exitCode, + string? jsonError, + string standardError) + { + string detail = !string.IsNullOrWhiteSpace(jsonError) + ? jsonError + : standardError.Trim(); + return detail.Length == 0 + ? $"The bake tool exited with code {exitCode}." + : $"The bake tool exited with code {exitCode}: {detail}"; + } + + private static void Report( + IProgress? progress, + LauncherInstallPhase phase, + string status, + long completed = 0, + long total = 0, + int failures = 0, + double etaSeconds = 0) => + progress?.Report(new LauncherInstallProgress( + phase, + status, + completed, + total, + failures, + etaSeconds)); + + private static string FormatMissing(IReadOnlyList missing) => + missing.Count == 0 + ? string.Empty + : " Missing: " + string.Join(", ", missing) + "."; + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); +} diff --git a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs index 327d406a..dab09b90 100644 --- a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs +++ b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs @@ -27,6 +27,11 @@ public static class FileIntegrity return Convert.ToHexStringLower(hash); } + /// + /// Asynchronous, cancellable counterpart used while verifying a multi- + /// gigabyte prepared package. The file stays streamed and no buffer is + /// retained after the hash completes. + /// public static async Task ComputeSha256HexAsync( string filePath, CancellationToken cancellationToken = default) @@ -38,8 +43,8 @@ public static class FileIntegrity FileMode.Open, FileAccess.Read, FileShare.Read, - bufferSize: 4096, - useAsync: true); + bufferSize: 1024 * 1024, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken) .ConfigureAwait(false); return Convert.ToHexStringLower(hash); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs index 5d8e0c07..c89c279a 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -3,10 +3,24 @@ namespace AcDream.Launcher.Core.Launching; /// /// The DAT/pak locations a completed install (LA9) records and every /// session-config composition consumes for -/// . SHA-256/version bookkeeping -/// for the install record itself is LA9/LA10 scope; this slice only -/// needs the two paths a session config requires. +/// . Integrity metadata is launcher- +/// local: it is verified before this record is admitted to the orchestrator +/// and is deliberately not copied into the host session-config contract. /// public sealed record LauncherInstallRecord( string DatDirectory, - string PreparedAssetPath); + string PreparedAssetPath, + string PreparedAssetSha256 = "", + long PreparedAssetSize = 0, + uint BakeToolVersion = 0) +{ + public const int CurrentRecordVersion = 1; + + public int Version { get; init; } = CurrentRecordVersion; + + public bool HasIntegrityMetadata => + !string.IsNullOrEmpty(PreparedAssetSha256) + && PreparedAssetSha256.Length == 64 + && PreparedAssetSize > 0 + && BakeToolVersion > 0; +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index 57d344c9..c82b8dcc 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -29,6 +29,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator private readonly List _activities = []; private LauncherInstallRecord? _installRecord; + private string _installationStatus; private bool _disposed; public LauncherOrchestrator( @@ -40,7 +41,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ILauncherSessionConfigService? configService = null, ILauncherProcessSupervisorFactory? supervisorFactory = null, IStatusEventSourceFactory? statusSourceFactory = null, - Func? sessionIdFactory = null) + Func? sessionIdFactory = null, + string? installationStatus = null) { _profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore)); _paths = paths ?? throw new ArgumentNullException(nameof(paths)); @@ -51,6 +53,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator _supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory(); _statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory(); _sessionIdFactory = sessionIdFactory ?? CreateSessionId; + _installationStatus = installationStatus + ?? (installRecord is null + ? FirstRunRequired + : "Client content paths are configured."); } public event EventHandler? StateChanged; @@ -85,9 +91,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator sessions, _platform, _installRecord is not null, - _installRecord is null - ? FirstRunRequired - : "Client content paths are configured."); + _installationStatus); } } @@ -184,6 +188,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { ThrowIfDisposed(); _installRecord = installRecord; + _installationStatus = installRecord is null + ? FirstRunRequired + : "Client content SHA-256, size, and bake-tool version verified."; } RaiseStateChanged(); diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index 75dd93fe..2723ed1d 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -1,3 +1,4 @@ +using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Orchestration; using AcDream.Launcher.Core.Profiles; @@ -22,15 +23,40 @@ public sealed partial class App : Application { ApplicationPathSet paths = ApplicationPathSet.Resolve(); LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); - LauncherInstallRecord? install = ResolveDevelopmentInstallRecord(); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + var installer = new LauncherInstaller( + paths, + Path.Combine( + AppContext.BaseDirectory, + "acdream-bake" + executableSuffix)); + InstallRecordVerification verification; + try + { + // Hashing the package before constructing the orchestrator is + // intentional: no launch action is enabled until the persisted + // size/SHA/tool-version record has been verified. + verification = installer.LoadExistingAsync() + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + verification = new InstallRecordVerification( + InstallRecordVerificationState.Invalid, + null, + $"Client content verification failed: {ex.Message}"); + } + _orchestrator = new LauncherOrchestrator( profiles, paths, LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory), - install); + verification.Record, + installationStatus: verification.Status); _viewModel = new LauncherWindowViewModel( _orchestrator, - new AvaloniaUiDispatcher()); + new AvaloniaUiDispatcher(), + installer); _viewModel.Initialize(); desktop.MainWindow = new MainWindow @@ -43,19 +69,6 @@ public sealed partial class App : Application base.OnFrameworkInitializationCompleted(); } - private static LauncherInstallRecord? ResolveDevelopmentInstallRecord() - { - // LA9 owns persisted install discovery. LA4 accepts the existing - // developer environment pair at this one composition root so the - // launch/probe UI can be exercised before the first-run body lands. - string? datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? preparedAssetPath = Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH"); - return !string.IsNullOrWhiteSpace(datDirectory) - && !string.IsNullOrWhiteSpace(preparedAssetPath) - ? new LauncherInstallRecord(datDirectory, preparedAssetPath) - : null; - } - private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) { _viewModel?.Dispose(); diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index f85d1e8a..9af0408c 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -342,21 +342,88 @@ KeyDown="OnModalKeyDown" AutomationProperties.Name="First-run setup modal dialog" IsVisible="{Binding FirstRunWizardShell.IsOpen}"> - - - - - - - - public Dictionary? CharacterOptions { get; init; } - /// LA1: plugin ids to load. Absent = load all (LA5 consumes - /// this; parsed and carried here now per the pinned launch contract). + /// LA1/LA5: plugin ids to load. Absent = load all; explicit + /// empty = load none. public List? Plugins { get; init; } - /// LA1: ordered chat-typed strings run after entering world - /// (LA6 consumes this; parsed and carried here now). + /// LA1/LA6: ordered chat-typed strings run through the shared + /// Runtime parser/router after entering world. public List? LoginCommands { get; init; } /// LA1: inter-command delay for , diff --git a/src/AcDream.App/GlobalUsings.cs b/src/AcDream.App/GlobalUsings.cs index 5213b6cc..83a9ab4d 100644 --- a/src/AcDream.App/GlobalUsings.cs +++ b/src/AcDream.App/GlobalUsings.cs @@ -1,4 +1,5 @@ global using AcDream.Runtime.Gameplay; global using AcDream.Runtime.Physics; +global using AcDream.Runtime.Chat; global using ILocalPlayerMotionSource = AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource; diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs index dd59d9f6..50f04757 100644 --- a/src/AcDream.App/Net/ILiveInWorldSource.cs +++ b/src/AcDream.App/Net/ILiveInWorldSource.cs @@ -12,5 +12,5 @@ internal interface ILiveWorldSessionSource internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource { - AcDream.UI.Abstractions.ICommandBus Commands { get; } + AcDream.Runtime.Chat.ICommandBus Commands { get; } } diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 0b0b44d4..2d781197 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -1,10 +1,8 @@ using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.Core.Items; -using AcDream.Core.Net.Messages; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; -using AcDream.UI.Abstractions; namespace AcDream.App.Net; @@ -149,6 +147,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting { private readonly object _gate = new(); private LiveCommandBus? _commands; + private LiveChatCommandRoute? _chatCommands; private ClientCommandController.Bindings? _clientCommands; private int _state; // 0 = constructed, 1 = active, 2 = disposed @@ -170,19 +169,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting var commands = new LiveCommandBus(); var clientCommands = new ClientCommandController( BuildGuardedClientCommands(bindings.ClientCommands)); - commands.Register(clientCommands.Execute); - commands.Register(command => - { - if (!string.IsNullOrEmpty(command.Text)) - SendIfActive(() => bindings.SendTalk(command.Text)); - }); - commands.Register(command => RouteChat(bindings, command)); + _chatCommands = new LiveChatCommandRoute(new LiveChatCommandBindings( + clientCommands.Execute, + bindings.Communication, + bindings.Chat, + bindings.TurbineChat, + bindings.CharacterState, + bindings.PlayerGuid, + bindings.SendTalk, + bindings.SendTell, + bindings.SendChannel, + bindings.SendTurbineChat, + bindings.Log)); // Campaign CH slice CH4 (2026-08-09): the 22 unregistered // ChannelSystem::GetChannelID fallback tags — bypasses // ChatChannelKind/ChannelResolver entirely and sends the raw // legacy ChatChannel (0x0147) broadcast directly. - commands.Register( - command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text))); commands.Register( command => SendIfActive(() => bindings.AddShortcut(command.Entry))); commands.Register( @@ -320,6 +322,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting { if (_state == 2) throw new ObjectDisposedException(nameof(LiveSessionCommandRouter)); + _chatCommands?.Activate(); _state = 1; } } @@ -329,202 +332,31 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting lock (_gate) { if (_state == 1) - _commands?.Publish(command); + { + if (_chatCommands?.TryPublish(command) != true) + _commands?.Publish(command); + } } } public void Dispose() { LiveCommandBus? commands; + LiveChatCommandRoute? chatCommands; lock (_gate) { _state = 2; commands = _commands; _commands = null; + chatCommands = _chatCommands; + _chatCommands = null; _clientCommands = null; } + chatCommands?.Dispose(); commands?.Clear(); } - /// - /// The seven values that ride Turbine - /// (0xF7DE), mapped to the lighter - /// reads. Every OTHER channel - /// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast) - /// is legacy-only (0x0147) — those pipelines never overlap Turbine. - /// is the one exception, and - /// special-cases it BEFORE this table is - /// consulted: S3 (CH3 Opus review, 2026-08-09) corrected the original - /// CH3 filing (research doc §5.3) — retail's /a is bound to the - /// LEGACY AllegianceBroadcast bitflag by default and is only - /// rebound to DoTurbineChat_Allegiance once - /// StartupTurbineChatSystem successfully starts Turbine chat - /// (research doc §4.3). So "Turbine never started" (TurbineChat. - /// Enabled == false) still falls back to legacy, while "Turbine is - /// up but this character has no allegiance room" (Enabled == true, - /// AllegianceRoom == 0) correctly keeps retail's local - /// "Turbine chat is not available." refusal at the membership gate. - /// - private static readonly Dictionary TurbineChannelKinds = new() - { - [ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance, - [ChatChannelKind.General] = ChatChannelKindLite.General, - [ChatChannelKind.Trade] = ChatChannelKindLite.Trade, - [ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg, - [ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay, - [ChatChannelKind.Society] = ChatChannelKindLite.Society, - [ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi, - }; - - private void RouteChat( - LiveSessionCommandBindings bindings, - SendChatCmd command) - { - if (string.IsNullOrEmpty(command.Text)) - return; - - switch (command.Channel) - { - case ChatChannelKind.Say: - // ACE echoes HearSpeech to the sender. Retail therefore uses - // the authoritative inbound line rather than a local echo. - SendIfActive(() => bindings.SendTalk(command.Text)); - return; - - case ChatChannelKind.Tell: - if (string.IsNullOrEmpty(command.TargetName)) - return; - if (!SendIfActive(() => - bindings.SendTell(command.TargetName, command.Text))) - return; - bindings.Chat.OnSelfSent( - ChatKind.Tell, - command.Text, - // Retail's own "You tell ..." echo is Speech_Direct_Send - // (0x04), distinct from an incoming Tell's 0x03 — see - // ChatMessageType.OutgoingTell's "You tell ..." comment. - logTextType: (uint)RetailLogTextType.SpeechDirectSend, - targetOrChannel: command.TargetName); - return; - } - - // S3 (CH3 Opus review, 2026-08-09): see the TurbineChannelKinds doc - // comment above — Turbine chat never having started (no 0x0295 - // SetTurbineChatChannels received at all) still routes /a through - // the legacy AllegianceBroadcast bitflag, exactly like retail's - // default binding before StartupTurbineChatSystem runs. - if (command.Channel == ChatChannelKind.Allegiance - && !bindings.TurbineChat.Enabled) - { - RouteLegacyChannel(bindings, ChatChannelKind.AllegianceBroadcast, command.Text); - return; - } - - if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind)) - { - RouteTurbineChat(bindings, liteKind, command.Text); - return; - } - - RouteLegacyChannel(bindings, command.Channel, command.Text); - } - - /// - /// Step 2 of the CH3 fix list: retail - /// ClientCommunicationSystem::SendTurbineChat @0x0057db10's local - /// membership gate, raised through the same - /// RuntimeCommunicationState.AddText chokepoint CH2 built for - /// every other client-raised refusal. - /// - private void RouteTurbineChat( - LiveSessionCommandBindings bindings, - ChatChannelKindLite kind, - string text) - { - TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( - kind, - bindings.TurbineChat, - bindings.CharacterState.Options, - bindings.CharacterState.IsOlthoiPlayer); - - // N3 (CH3 Opus review): the gate-result-to-refusal-text mapping is - // now shared with DirectGameRuntimeCommandAdapter.TrySendChannel via - // TurbineChatMembershipGate.ResolveRefusalText — this used to be an - // independent copy of the same switch. - if (gate.Status != TurbineChatGateStatus.Allowed) - { - if (TurbineChatMembershipGate.ResolveRefusalText(gate) is - (string refusalText, RetailLogTextType refusalType)) - { - bindings.Communication.AddText(refusalText, refusalType); - } - return; - } - - uint cookie = bindings.TurbineChat.NextContextId(); - uint senderGuid = bindings.PlayerGuid(); - bindings.Log?.Invoke( - $"chat: outbound TurbineChat {gate.DisplayName} " + - $"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " + - $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}"); - SendIfActive(() => bindings.SendTurbineChat( - gate.RoomId, - gate.ChatType, - (uint)TurbineChat.DispatchType.SendToRoomById, - senderGuid, - text, - cookie)); - } - - private void RouteLegacyChannel( - LiveSessionCommandBindings bindings, - ChatChannelKind channel, - string text) - { - ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel); - if (legacy is null) - { - bindings.Log?.Invoke( - $"chat: SendChatCmd kind={channel} dropped (no legacy id)"); - return; - } - - bindings.Log?.Invoke( - $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + - $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}"); - if (!SendIfActive(() => - bindings.SendChannel(legacy.Value.ChannelId, text))) - return; - - // Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends - // Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an - // empty sender name, so a local optimistic echo double-prints. S1 - // (CH3 Opus review, 2026-08-09) corrected AllegianceBroadcast into - // this SAME group: ACE's GameActionChatChannel handler iterates - // player.Allegiance.Members and the sender is one of them, so they - // get their own line back with their real name too — a different - // mechanism (no separate ""-sender resend) but the same - // double-print risk, so it must ALSO skip the local echo (research - // doc §3.7/§5.4, corrected). - bool serverEchoes = new ChatChannelInfo.Legacy( - legacy.Value.ChannelId, - legacy.Value.DisplayName).IsSelfEchoChannel(); - if (serverEchoes) - return; - - bindings.Chat.OnSelfSent( - ChatKind.Channel, - text, - targetOrChannel: legacy.Value.DisplayName, - // Precise per-bit own-send type (LegacyChannelChatType.Resolve's - // ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/ - // Vassal/Follower become 0x0B, the admin/audit/sentinel - // catch-all becomes 0x09 Channel_Send (corrected 2026-08-09, - // Opus review of 172c6f9a — was wrongly 0x0E). - logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true)); - } - private ClientCommandController.Bindings BuildGuardedClientCommands( ClientCommandController.Bindings source) => new( TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 805d0170..8a9e3886 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -28,6 +28,7 @@ using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; +using AcDream.Runtime.Chat; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; using DatReaderWriter; @@ -106,6 +107,9 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveMovementStatsApplier _movementStats; private readonly SessionStatusWriter _statusWriter; private readonly string _sessionId; + private readonly IReadOnlyList _loginCommands; + private readonly TimeSpan _loginCommandDelay; + private readonly TimeProvider _timeProvider; public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -116,7 +120,10 @@ internal sealed class LiveSessionRuntimeFactory LiveSessionCommandSurface commands, Action log, SessionStatusWriter? statusWriter = null, - string sessionId = "app") + string sessionId = "app", + IReadOnlyList? loginCommands = null, + int loginCommandDelayMs = 500, + TimeProvider? timeProvider = null) { _player = player ?? throw new ArgumentNullException(nameof(player)); _domain = domain ?? throw new ArgumentNullException(nameof(domain)); @@ -130,6 +137,14 @@ internal sealed class LiveSessionRuntimeFactory // status file configured — every call site below stays unconditional. _statusWriter = statusWriter ?? new SessionStatusWriter(null); _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + if (loginCommandDelayMs < 0) + { + throw new ArgumentOutOfRangeException( + nameof(loginCommandDelayMs)); + } + _loginCommands = loginCommands is null ? [] : [.. loginCommands]; + _loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs); + _timeProvider = timeProvider ?? TimeProvider.System; // C3c-F1: stat recomputes route through the Runtime movement owner's // typed application seam; App keeps zero direct controller mutations. _movementStats = new LiveMovementStatsApplier( @@ -150,6 +165,17 @@ internal sealed class LiveSessionRuntimeFactory LiveSessionResetPlan reset = LiveSessionResetManifest.Create( CreateResetBindings(resetHost)); + var loginCommands = new LoginCommandSequence( + _loginCommands, + _loginCommandDelay, + new RuntimeChatCommandFeedback(_domain.Communication), + _commands, + failure => _statusWriter.LoginCommandFailed( + _sessionId, + failure.CommandIndex, + failure.Command, + failure.Error), + _timeProvider); return new LiveSessionHost(controller, new LiveSessionHostBindings( Routing: new( CreateEventRouter, @@ -194,7 +220,8 @@ internal sealed class LiveSessionRuntimeFactory CharacterEntered: selection => _statusWriter.EnteredWorld( _sessionId, selection.CharacterId, - selection.CharacterName)), + selection.CharacterName), + LoginCommands: loginCommands), connectOptions); } diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index b0c4bd72..91772711 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -87,10 +87,10 @@ public sealed record RuntimeOptions( string? StatusFilePath, /// Campaign LA slice LA1: plugin ids to load. /// = load every discovered plugin (today's - /// behavior). Consumed by LA5; parsed and carried now. + /// behavior). Consumed by the shared graphical plugin session. IReadOnlyList? Plugins, /// Campaign LA slice LA1: ordered chat-typed strings run once - /// entered-world. Consumed by LA6; parsed and carried now. + /// entered-world through the shared Runtime parser/router. IReadOnlyList LoginCommands, /// Campaign LA slice LA1: inter-command delay for /// , milliseconds. diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 244258ea..182f5f8b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -207,7 +207,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// Widget tree from . /// Chat view-model (transcript data + command routing). /// Factory that returns the live command bus at submit time. - /// Called on every chat submit so it resolves + /// Called on every chat submit so it resolves /// even when the live session is established AFTER runs /// (mirrors the ImGui ChatPanel which re-reads the bus each frame). /// Runtime's canonical per-window filter/open state diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index fdae1225..6bf5f0e1 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -99,16 +99,15 @@ internal sealed record HeadlessSessionDescriptor /// /// Campaign LA slice LA1: plugin ids to load from the standard plugins /// directory (docs/plans/2026-08-14-launcher-campaign.md LA1). - /// Absent means load every discovered plugin (today's dev behavior); - /// LA5 wires this into an actual allow-list filter. Parsed and carried - /// here now so the session-config shape is stable before LA5 lands. + /// Absent means load every discovered plugin (the developer flow); + /// explicit empty means load none. LA5 host composition consumes this + /// as the actual allow-list filter. /// public List? Plugins { get; init; } /// - /// Campaign LA slice LA1: ordered chat-typed strings run once the - /// session enters world. LA6 wires actual execution; parsed and carried - /// here now. + /// Campaign LA slice LA1/LA6: ordered chat-typed strings run through the + /// shared Runtime parser/router once the session enters world. /// public List? LoginCommands { get; init; } @@ -123,8 +122,7 @@ internal sealed record HeadlessSessionDescriptor /// /// Campaign LA slice LA1: absolute path for this session's status-event /// JSONL stream (docs/superpowers/specs/2026-08-14-launcher-campaign-design.md - /// §6). Absent means no - /// is constructed for this session. + /// §6). Absent selects the writer's permanent no-op mode. /// public string? StatusFile { get; init; } } diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 27686c1e..389b2a6f 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -323,8 +323,9 @@ internal static class HeadlessConfigurationLoader /// Campaign LA slice LA1: validates the four new optional per-session /// fields shared with the App session-config reader (see /// docs/plans/2026-08-14-launcher-campaign.md LA1's pinned - /// contract). All four stay optional; only their SHAPE is checked here - /// — parsing/executing plugins/loginCommands is LA5/LA6. + /// contract). All four stay optional; this loader owns their strict + /// shape checks while LA5/LA6 host composition consumes the resulting + /// plugin allow-list and ordered login-command sequence. /// private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session) { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 9e025f61..5219f94c 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -6,6 +6,7 @@ using AcDream.Headless.Policies; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime; +using AcDream.Runtime.Chat; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; @@ -14,29 +15,54 @@ namespace AcDream.Headless.Hosting; internal sealed class HeadlessSessionHost : IDisposable { - private sealed class SessionCommandRoute( - ILiveSessionCommandRouting gameplay, - ILiveSessionCommandRouting commands) - : ILiveSessionCommandRouting + private sealed class SessionCommandRoute : ILiveSessionCommandRouting { - private bool _gameplayActive; - private bool _commandsActive; + private ILiveSessionCommandRouting? _gameplay; + private ILiveSessionCommandRouting? _commands; + private ILiveSessionCommandRouting? _chat; + private bool _activated; + + internal SessionCommandRoute( + ILiveSessionCommandRouting gameplay, + ILiveSessionCommandRouting commands, + ILiveSessionCommandRouting chat) + { + _gameplay = gameplay + ?? throw new ArgumentNullException(nameof(gameplay)); + _commands = commands + ?? throw new ArgumentNullException(nameof(commands)); + _chat = chat + ?? throw new ArgumentNullException(nameof(chat)); + } public void Activate() { - if (_gameplayActive || _commandsActive) + if (_activated) return; - gameplay.Activate(); - _gameplayActive = true; + if (_gameplay is null || _commands is null || _chat is null) + throw new ObjectDisposedException(nameof(SessionCommandRoute)); + + _gameplay.Activate(); try { - commands.Activate(); - _commandsActive = true; + _commands.Activate(); + _chat.Activate(); + _activated = true; } - catch + catch (Exception activationError) { - gameplay.Dispose(); - _gameplayActive = false; + try + { + Dispose(); + } + catch (Exception disposalError) + { + throw new AggregateException( + "Headless command-route activation and rollback failed.", + activationError, + disposalError); + } + throw; } } @@ -44,30 +70,9 @@ internal sealed class HeadlessSessionHost : IDisposable public void Dispose() { List? failures = null; - if (_commandsActive) - { - try - { - commands.Dispose(); - } - catch (Exception error) - { - (failures ??= []).Add(error); - } - _commandsActive = false; - } - if (_gameplayActive) - { - try - { - gameplay.Dispose(); - } - catch (Exception error) - { - (failures ??= []).Add(error); - } - _gameplayActive = false; - } + TryDispose(ref _chat, ref failures); + TryDispose(ref _commands, ref failures); + TryDispose(ref _gameplay, ref failures); if (failures is not null) { throw new AggregateException( @@ -75,6 +80,24 @@ internal sealed class HeadlessSessionHost : IDisposable failures); } } + + private static void TryDispose( + ref ILiveSessionCommandRouting? route, + ref List? failures) + { + if (route is not { } current) + return; + + try + { + current.Dispose(); + route = null; + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } } private sealed class SessionCommandBridge : IRuntimeSessionCommands @@ -301,6 +324,18 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); + var chatCommandSurface = new LiveChatCommandSurface(); + var loginCommands = new LoginCommandSequence( + descriptor.LoginCommands, + TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs), + new RuntimeChatCommandFeedback(runtime.CommunicationOwner), + chatCommandSurface, + failure => statusWriter.LoginCommandFailed( + descriptor.Id, + failure.CommandIndex, + failure.Command, + failure.Error), + _timeProvider); pluginSession = HeadlessPluginSession.Create( runtime, diagnostics, @@ -315,7 +350,12 @@ internal sealed class HeadlessSessionHost : IDisposable CreateEventRoute, session => new SessionCommandRoute( gameplay.CreateRoute(session), - commands.CreateRoute(session))), + commands.CreateRoute(session), + chatCommandSurface.Attach( + new LiveChatCommandRoute( + CreateChatCommandBindings( + session, + runtime))))), generation => runtime.ResetGeneration(generation, _resetHost), new LiveSessionSelectionBindings( @@ -368,7 +408,8 @@ internal sealed class HeadlessSessionHost : IDisposable selection => statusWriter.EnteredWorld( descriptor.Id, selection.CharacterId, - selection.CharacterName))); + selection.CharacterName), + loginCommands)); Runtime = runtime; Commands = commands; @@ -506,7 +547,7 @@ internal sealed class HeadlessSessionHost : IDisposable _ = Runtime.Clock.Advance(deltaSeconds); _localPlayerFrame.AdvanceBeforeNetwork( checked((float)deltaSeconds)); - Runtime.Session.Tick(); + _liveSession.Tick(); // C3c: pump pending first-entry sequences after the network drain — // collision-generation progress and freshly accepted Creates both // surface here, mirroring the graphical per-frame retry phase. @@ -821,6 +862,142 @@ internal sealed class HeadlessSessionHost : IDisposable } } + private LiveChatCommandBindings CreateChatCommandBindings( + AcDream.Core.Net.WorldSession session, + GameRuntime runtime) => new( + ExecuteClientCommand: command => + ExecuteHeadlessClientCommand(session, runtime, command), + Communication: runtime.CommunicationOwner, + Chat: runtime.CommunicationOwner.Chat, + TurbineChat: runtime.CommunicationOwner.TurbineChat, + CharacterState: runtime.CharacterOwner, + PlayerGuid: () => runtime.PlayerIdentity.ServerGuid, + SendTalk: session.SendTalk, + SendTell: session.SendTell, + SendChannel: session.SendChannel, + SendTurbineChat: session.SendTurbineChatTo, + Log: message => _diagnostics.Message( + _descriptor.Id, + message, + runtime.Generation.Value)); + + /// + /// Presentation-free subset of retail client commands. Commands whose + /// semantics require a graphical confirmation/window or a host-specific + /// presentation service fail explicitly; the login sequence reports that + /// one line and continues. Wire-only and canonical-state commands take + /// the exact same WorldSession/Runtime paths as the graphical bindings. + /// + private static void ExecuteHeadlessClientCommand( + AcDream.Core.Net.WorldSession session, + GameRuntime runtime, + ExecuteClientCommandCmd command) + { + switch (command.Command) + { + case ClientCommandId.LifestoneRecall: + session.SendTeleportToLifestone(); + return; + case ClientCommandId.MarketplaceRecall: + session.SendTeleportToMarketplace(); + return; + case ClientCommandId.PkArenaRecall: + session.SendTeleportToPkArena(); + return; + case ClientCommandId.PkLiteArenaRecall: + session.SendTeleportToPkLiteArena(); + return; + case ClientCommandId.EnterPkLite: + session.SendEnterPkLite(); + return; + case ClientCommandId.HouseRecall: + session.SendTeleportToHouse(); + return; + case ClientCommandId.MansionRecall: + session.SendTeleportToMansion(); + return; + case ClientCommandId.QueryAge: + session.SendQueryAge(); + return; + case ClientCommandId.QueryBirth: + session.SendQueryBirth(); + return; + case ClientCommandId.Emote + when !string.IsNullOrWhiteSpace(command.Arguments): + session.SendEmote(command.Arguments.Trim()); + return; + case ClientCommandId.ClearChat: + runtime.CommunicationOwner.Chat.Clear(); + return; + case ClientCommandId.IndexChannels: + session.SendIndexChannels(); + return; + case ClientCommandId.ListChannel: + SendResolvedChannel( + command.Arguments, + session.SendListChannel); + return; + case ClientCommandId.OnChannel: + SendResolvedChannel( + command.Arguments, + session.SendOnChannel); + return; + case ClientCommandId.OffChannel: + SendResolvedChannel( + command.Arguments, + session.SendOffChannel); + return; + case ClientCommandId.AllegianceHometown: + session.SendRecallAllegianceHometown(); + return; + case ClientCommandId.AllegianceInfo: + session.SendAllegianceInfoRequest(command.Arguments.Trim()); + return; + case ClientCommandId.HouseAvailableList + when RetailClientCommandCatalog.TryResolveHouseType( + command.Arguments, + out uint houseType): + session.SendListAvailableHouses(houseType); + return; + case ClientCommandId.JoinChannel + when RetailClientCommandCatalog.TryResolveJoinLeaveOption( + command.Arguments, + out uint joinOption): + _ = runtime.CharacterOwner.Options.TrySetOption( + joinOption, + true, + session.SendSetSingleCharacterOption); + return; + case ClientCommandId.LeaveChannel + when RetailClientCommandCatalog.TryResolveJoinLeaveOption( + command.Arguments, + out uint leaveOption): + _ = runtime.CharacterOwner.Options.TrySetOption( + leaveOption, + false, + session.SendSetSingleCharacterOption); + return; + default: + throw new NotSupportedException( + $"Client command '{command.Command}' is not available " + + "in the headless host."); + } + + static void SendResolvedChannel( + string arguments, + Action send) + { + if (!RetailChannelTagTable.TryResolve( + arguments.Trim(), + out uint channelId)) + { + throw new InvalidOperationException( + $"Chat channel '{arguments.Trim()}' does not exist."); + } + send(channelId); + } + } + private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index 57d344c9..358dc4f9 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -849,6 +849,11 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator activity.Error = $"Plugin failed: {failed.Plugin}: {failed.Error}"; activity.Status = activity.Error; break; + case LoginCommandFailedStatusEvent failed: + activity.Error = + $"Login command {failed.CommandIndex} failed: {failed.Error}"; + activity.Status = activity.Error; + break; case DisconnectedStatusEvent disconnected: if (activity.State != LauncherActivityState.Stopping) { diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index c090b6e3..2efbf5a9 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -56,6 +56,15 @@ public sealed record PluginFailedStatusEvent : StatusEvent public required string Error { get; init; } } +public sealed record LoginCommandFailedStatusEvent : StatusEvent +{ + public required int CommandIndex { get; init; } + + public required string Command { get; init; } + + public required string Error { get; init; } +} + public sealed record DisconnectedStatusEvent : StatusEvent { public required string Reason { get; init; } diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index f6811a60..4f5000da 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -101,6 +101,8 @@ public static class StatusEventParser ParsePluginLoaded(root, v, e, t, sessionId), "pluginFailed" => ParsePluginFailed(root, v, e, t, sessionId), + "loginCommandFailed" => + ParseLoginCommandFailed(root, v, e, t, sessionId), "disconnected" => ParseDisconnected(root, v, e, t, sessionId), "exited" => @@ -128,6 +130,7 @@ public static class StatusEventParser "enteredWorld" or "pluginLoaded" or "pluginFailed" or + "loginCommandFailed" or "disconnected" or "exited"; @@ -280,6 +283,32 @@ public static class StatusEventParser Reason = RequireString(root, "reason"), }; + private static StatusEvent ParseLoginCommandFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) + { + int commandIndex = RequireInt32(root, "commandIndex"); + if (commandIndex < 0) + { + throw new FormatException( + "status event field 'commandIndex' is negative."); + } + + return new LoginCommandFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + CommandIndex = commandIndex, + Command = RequireString(root, "command"), + Error = RequireString(root, "error"), + }; + } + private static StatusEvent ParseExited( JsonElement root, int v, diff --git a/src/AcDream.UI.Abstractions/ChannelResolver.cs b/src/AcDream.Runtime/Chat/ChannelResolver.cs similarity index 98% rename from src/AcDream.UI.Abstractions/ChannelResolver.cs rename to src/AcDream.Runtime/Chat/ChannelResolver.cs index f357efc5..0c204fc4 100644 --- a/src/AcDream.UI.Abstractions/ChannelResolver.cs +++ b/src/AcDream.Runtime/Chat/ChannelResolver.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Maps a to the legacy ChatChannel diff --git a/src/AcDream.UI.Abstractions/ChatChannelKind.cs b/src/AcDream.Runtime/Chat/ChatChannelKind.cs similarity index 98% rename from src/AcDream.UI.Abstractions/ChatChannelKind.cs rename to src/AcDream.Runtime/Chat/ChatChannelKind.cs index a1861c5a..63060253 100644 --- a/src/AcDream.UI.Abstractions/ChatChannelKind.cs +++ b/src/AcDream.Runtime/Chat/ChatChannelKind.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Outbound chat channel selector. Mirrors holtburger's ChatChannelKind diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs similarity index 84% rename from src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs rename to src/AcDream.Runtime/Chat/ChatCommandRouter.cs index 9b3c090b..f370f176 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs @@ -2,7 +2,7 @@ using System; using System.Linq; using AcDream.Core.Chat; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// What a submit did, so the caller can clear its input + give feedback. /// UnknownCommand is produced only for command-shaped but verbless @@ -11,8 +11,8 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped /// /// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @ -/// 0x004F5100 analogue). Both the ImGui devtools -/// and retained retail chat window route through here. +/// 0x004F5100 analogue). Every graphical and headless chat entrance routes +/// through here. /// /// /// Flow: emote-prefix rewrite, retail client-command catalog, local @@ -20,17 +20,20 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped /// ChannelSystem::GetChannelID fallback (unregistered GM/faction /// channel tags), explicit server command, then chat parse. Unknown /// slash/at verbs publish in canonical -/// @ form; the App host sends those through Talk, the only wire path -/// ACE parses commands on. Prefix text with no letter verb is refused locally -/// so command-shaped input can never leak into speech. +/// @ form; the active host sends those through Talk, the only wire +/// path ACE parses commands on. Prefix text with no letter verb is refused +/// locally so command-shaped input can never leak into speech. /// /// public static class ChatCommandRouter { public static SubmitOutcome Submit( - string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + string? raw, + IChatCommandFeedback feedback, + ICommandBus bus, + ChatChannelKind defaultChannel) { - ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(feedback); ArgumentNullException.ThrowIfNull(bus); string trimmed = (raw ?? string.Empty).Trim(); if (trimmed.Length == 0) @@ -80,7 +83,7 @@ public static class ChatCommandRouter // ClientLocal is correct for it. if (clientCommand.InvalidArgumentsText is { } bespokeRefusal) { - vm.ShowInterfaceText(bespokeRefusal); + feedback.ShowInterfaceText(bespokeRefusal); } else { @@ -88,9 +91,9 @@ public static class ChatCommandRouter WeenieErrorMessages.Resolve(0x026u, null); string fallbackText = fallback.text ?? "That is not a valid command."; if (fallback.type == RetailLogTextType.ClientLocal) - vm.ShowInterfaceText(fallbackText); + feedback.ShowInterfaceText(fallbackText); else - vm.ShowSystemMessage(fallbackText); + feedback.ShowSystemMessage(fallbackText); } return SubmitOutcome.ClientHandled; } @@ -100,7 +103,7 @@ public static class ChatCommandRouter return SubmitOutcome.ClientHandled; } - if (TryHandleLocalPresentationCommand(trimmed, vm)) + if (TryHandleLocalPresentationCommand(trimmed, feedback)) return SubmitOutcome.ClientHandled; // Command-shaped but no letter verb ("/", "//shrug", "@ x"): @@ -112,7 +115,7 @@ public static class ChatCommandRouter if (trimmed[0] is '/' or '@' && (trimmed.Length == 1 || !char.IsLetter(trimmed[1]))) { - vm.ShowInterfaceText( + feedback.ShowInterfaceText( $"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands."); return SubmitOutcome.UnknownCommand; } @@ -130,7 +133,7 @@ public static class ChatCommandRouter // would resolve them too, but retail never reaches this fallback // for a REGISTERED verb (it's intercepted by the main hash table // first). - SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, vm, bus); + SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, bus); if (fallbackOutcome is { } outcome) return outcome; @@ -147,18 +150,23 @@ public static class ChatCommandRouter // null" shape does for these cases. if (ChatInputParser.IsBareRegisteredChannelVerb(trimmed)) { - vm.ShowInterfaceText("You must specify the text you wish to say!"); + feedback.ShowInterfaceText("You must specify the text you wish to say!"); return SubmitOutcome.ClientHandled; } - if (ChatInputParser.IsReplyMissingLastTeller(trimmed, vm.LastIncomingTellSender)) + if (ChatInputParser.IsReplyMissingLastTeller( + trimmed, + feedback.LastIncomingTellSender)) { - vm.ShowInterfaceText("Someone must @tell you first!"); + feedback.ShowInterfaceText("Someone must @tell you first!"); return SubmitOutcome.ClientHandled; } var parsed = ChatInputParser.Parse( - trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + trimmed, + defaultChannel, + feedback.LastIncomingTellSender, + feedback.LastOutgoingTellTarget); if (parsed is { } chat) { bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text)); @@ -173,7 +181,9 @@ public static class ChatCommandRouter /// fallback channel tags (caller continues its own dispatch chain); /// otherwise returns the outcome to return immediately. /// - private static SubmitOutcome? TryDispatchChannelFallback(string trimmed, ChatVM vm, ICommandBus bus) + private static SubmitOutcome? TryDispatchChannelFallback( + string trimmed, + ICommandBus bus) { if (trimmed[0] is not ('/' or '@')) return null; @@ -240,11 +250,13 @@ public static class ChatCommandRouter return true; } - private static bool TryHandleLocalPresentationCommand(string trimmed, ChatVM vm) + private static bool TryHandleLocalPresentationCommand( + string trimmed, + IChatCommandFeedback feedback) { if (EqAny(trimmed, "/help", "/?", "@help", "@?")) { - EmitBareHelp(vm); + EmitBareHelp(feedback); return true; } @@ -255,7 +267,7 @@ public static class ChatCommandRouter if (StartsWithAny(trimmed, "/help ", "@help ", "/? ", "@? ")) { string verb = trimmed[(trimmed.IndexOf(' ') + 1)..].Trim(); - EmitVerbHelp(verb, vm); + EmitVerbHelp(verb, feedback); return true; } @@ -268,10 +280,10 @@ public static class ChatCommandRouter /// user-gate round 3, finding (b) — see 's /// class remarks for the full print-sequence trace). /// - private static void EmitBareHelp(ChatVM vm) + private static void EmitBareHelp(IChatCommandFeedback feedback) { - vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); - vm.ShowSystemMessage(RetailCommandHelpTable.AvailableHelpListing); + feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); + feedback.ShowSystemMessage(RetailCommandHelpTable.AvailableHelpListing); } /// @@ -302,11 +314,13 @@ public static class ChatCommandRouter /// — chat-alias/channel verbs and the group-topic nodes, a disjoint key /// space from the catalog so this reordering changes nothing for them. /// - private static void EmitVerbHelp(string verb, ChatVM vm) + private static void EmitVerbHelp( + string verb, + IChatCommandFeedback feedback) { if (verb.Length == 0) { - EmitBareHelp(vm); + EmitBareHelp(feedback); return; } @@ -319,35 +333,35 @@ public static class ChatCommandRouter // SAME fallback an unregistered verb gets — DoHelp's help- // pointer-null guard skips its callback branch entirely. See // RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp's remarks. - vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand); + feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand); return; } if (RetailCommandHelpTable.TryGetCatalogVerbDetailText(normalized, out string retailDetailText)) { - vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); - vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + retailDetailText); + feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); + feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + retailDetailText); return; } if (RetailClientCommandCatalog.TryGetHelpText(normalized, out string catalogText)) { - vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); - vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + catalogText); + feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); + feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + catalogText); return; } if (RetailCommandHelpTable.TryGetHelpText(normalized, out string tableText)) { - vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); - vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + tableText); + feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote); + feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + tableText); return; } // Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367: - // now routed through ChatVM.ShowInterfaceText instead of the chat - // scroll — see the class remarks on RetailCommandHelpTable.UnknownCommand. - vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand); + // now routed through IChatCommandFeedback.ShowInterfaceText instead + // of the chat scroll — see RetailCommandHelpTable.UnknownCommand. + feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand); } private static bool EqAny(string value, params string[] options) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.Runtime/Chat/ChatInputParser.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs rename to src/AcDream.Runtime/Chat/ChatInputParser.cs index ae9a900b..24546e74 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.Runtime/Chat/ChatInputParser.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Phase I.4: pure-function parse of a chat-input line into the diff --git a/src/AcDream.UI.Abstractions/ClientCommandId.cs b/src/AcDream.Runtime/Chat/ClientCommandId.cs similarity index 95% rename from src/AcDream.UI.Abstractions/ClientCommandId.cs rename to src/AcDream.Runtime/Chat/ClientCommandId.cs index d4223d63..42aee416 100644 --- a/src/AcDream.UI.Abstractions/ClientCommandId.cs +++ b/src/AcDream.Runtime/Chat/ClientCommandId.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Backend-neutral identity for a command that retail executes in the client @@ -85,8 +85,8 @@ public enum ClientCommandId /// /// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with /// any subcommand beyond the 2 ported ones (info, hometown/ho). Never - /// dispatched — is always + /// dispatched — + /// is always /// false for this id, so ChatCommandRouter shows retail's own /// "Please see @help Allegiance..." refusal and never publishes an /// ExecuteClientCommandCmd. diff --git a/src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs b/src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs similarity index 89% rename from src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs rename to src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs index b5e95079..01e607d6 100644 --- a/src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs +++ b/src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// User intent to execute a retail client command. The application host owns diff --git a/src/AcDream.Runtime/Chat/IChatCommandFeedback.cs b/src/AcDream.Runtime/Chat/IChatCommandFeedback.cs new file mode 100644 index 00000000..0536569a --- /dev/null +++ b/src/AcDream.Runtime/Chat/IChatCommandFeedback.cs @@ -0,0 +1,17 @@ +namespace AcDream.Runtime.Chat; + +/// +/// The exact feedback surface used by the retail chat command parser/router. +/// Presentation hosts may implement it, while headless execution binds these +/// members directly to the canonical Runtime communication state. +/// +public interface IChatCommandFeedback +{ + void ShowInterfaceText(string text); + + void ShowSystemMessage(string text); + + string? LastIncomingTellSender { get; } + + string? LastOutgoingTellTarget { get; } +} diff --git a/src/AcDream.UI.Abstractions/ICommandBus.cs b/src/AcDream.Runtime/Chat/ICommandBus.cs similarity index 96% rename from src/AcDream.UI.Abstractions/ICommandBus.cs rename to src/AcDream.Runtime/Chat/ICommandBus.cs index 71742969..d6ddf82b 100644 --- a/src/AcDream.UI.Abstractions/ICommandBus.cs +++ b/src/AcDream.Runtime/Chat/ICommandBus.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Publishes user-intent commands from panels to the systems that handle diff --git a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs new file mode 100644 index 00000000..827f3348 --- /dev/null +++ b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs @@ -0,0 +1,345 @@ +using AcDream.Core.Chat; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Chat; + +/// +/// Exact live-session dependencies for the four chat-core command records. +/// Both graphical and no-window hosts bind this record to the active +/// WorldSession's send methods and the same canonical Runtime state. +/// +public sealed record LiveChatCommandBindings( + Action ExecuteClientCommand, + RuntimeCommunicationState Communication, + ChatLog Chat, + TurbineChatState TurbineChat, + RuntimeCharacterState CharacterState, + Func PlayerGuid, + Action SendTalk, + Action SendTell, + Action SendChannel, + Action SendTurbineChat, + Action? Log = null); + +/// +/// One generation's active binding for the four chat-core records. The route +/// becomes inert before the transport is disposed and clears every delegate +/// during teardown. +/// +public sealed class LiveChatCommandRoute + : ILiveSessionCommandRouting, + ICommandBus +{ + private static readonly Dictionary + TurbineChannelKinds = new() + { + [ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance, + [ChatChannelKind.General] = ChatChannelKindLite.General, + [ChatChannelKind.Trade] = ChatChannelKindLite.Trade, + [ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg, + [ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay, + [ChatChannelKind.Society] = ChatChannelKindLite.Society, + [ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi, + }; + + private readonly object _gate = new(); + private LiveCommandBus? _commands; + private int _state; // 0 = constructed, 1 = active, 2 = disposed + + public LiveChatCommandRoute(LiveChatCommandBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(bindings.ExecuteClientCommand); + ArgumentNullException.ThrowIfNull(bindings.Communication); + ArgumentNullException.ThrowIfNull(bindings.Chat); + ArgumentNullException.ThrowIfNull(bindings.TurbineChat); + ArgumentNullException.ThrowIfNull(bindings.CharacterState); + ArgumentNullException.ThrowIfNull(bindings.PlayerGuid); + ArgumentNullException.ThrowIfNull(bindings.SendTalk); + ArgumentNullException.ThrowIfNull(bindings.SendTell); + ArgumentNullException.ThrowIfNull(bindings.SendChannel); + ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat); + + var commands = new LiveCommandBus(); + commands.Register(command => + SendIfActive(() => bindings.ExecuteClientCommand(command))); + commands.Register(command => + { + if (!string.IsNullOrEmpty(command.Text)) + SendIfActive(() => bindings.SendTalk(command.Text)); + }); + commands.Register(command => RouteChat(bindings, command)); + commands.Register(command => + SendIfActive(() => + bindings.SendChannel(command.ChannelId, command.Text))); + _commands = commands; + } + + public bool IsActive + { + get + { + lock (_gate) + return _state == 1; + } + } + + public void Activate() + { + lock (_gate) + { + if (_state == 2) + throw new ObjectDisposedException(nameof(LiveChatCommandRoute)); + _state = 1; + } + } + + public void Publish(T command) where T : notnull + { + if (!TryPublish(command)) + { + Console.WriteLine( + $"[LiveChatCommandRoute] unsupported command type " + + $"{typeof(T).FullName}; dropping."); + } + } + + /// + /// Routes one of the four extracted command records and returns + /// . Other records are left to a host's sibling + /// command router and return without logging. + /// + public bool TryPublish(T command) where T : notnull + { + ArgumentNullException.ThrowIfNull(command); + Type type = typeof(T); + if (type != typeof(ExecuteClientCommandCmd) + && type != typeof(SendServerCommandCmd) + && type != typeof(SendChatCmd) + && type != typeof(SendRawChannelCmd)) + { + return false; + } + + lock (_gate) + { + if (_state == 1) + _commands?.Publish(command); + } + return true; + } + + public void Dispose() + { + LiveCommandBus? commands; + lock (_gate) + { + _state = 2; + commands = _commands; + _commands = null; + } + commands?.Clear(); + } + + private void RouteChat( + LiveChatCommandBindings bindings, + SendChatCmd command) + { + if (string.IsNullOrEmpty(command.Text)) + return; + + switch (command.Channel) + { + case ChatChannelKind.Say: + SendIfActive(() => bindings.SendTalk(command.Text)); + return; + + case ChatChannelKind.Tell: + if (string.IsNullOrEmpty(command.TargetName)) + return; + if (!SendIfActive(() => + bindings.SendTell(command.TargetName, command.Text))) + { + return; + } + bindings.Chat.OnSelfSent( + ChatKind.Tell, + command.Text, + logTextType: (uint)RetailLogTextType.SpeechDirectSend, + targetOrChannel: command.TargetName); + return; + } + + if (command.Channel == ChatChannelKind.Allegiance + && !bindings.TurbineChat.Enabled) + { + RouteLegacyChannel( + bindings, + ChatChannelKind.AllegianceBroadcast, + command.Text); + return; + } + + if (TurbineChannelKinds.TryGetValue( + command.Channel, + out ChatChannelKindLite liteKind)) + { + RouteTurbineChat(bindings, liteKind, command.Text); + return; + } + + RouteLegacyChannel(bindings, command.Channel, command.Text); + } + + private void RouteTurbineChat( + LiveChatCommandBindings bindings, + ChatChannelKindLite kind, + string text) + { + TurbineChatState turbineChat = bindings.TurbineChat; + TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( + kind, + turbineChat, + bindings.CharacterState.Options, + bindings.CharacterState.IsOlthoiPlayer); + + if (gate.Status != TurbineChatGateStatus.Allowed) + { + if (TurbineChatMembershipGate.ResolveRefusalText(gate) is + (string refusalText, RetailLogTextType refusalType)) + { + bindings.Communication.AddText(refusalText, refusalType); + } + return; + } + + uint cookie = turbineChat.NextContextId(); + uint senderGuid = bindings.PlayerGuid(); + bindings.Log?.Invoke( + $"chat: outbound TurbineChat {gate.DisplayName} " + + $"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " + + $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}"); + SendIfActive(() => bindings.SendTurbineChat( + gate.RoomId, + gate.ChatType, + (uint)TurbineChat.DispatchType.SendToRoomById, + senderGuid, + text, + cookie)); + } + + private void RouteLegacyChannel( + LiveChatCommandBindings bindings, + ChatChannelKind channel, + string text) + { + ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel); + if (legacy is null) + { + bindings.Log?.Invoke( + $"chat: SendChatCmd kind={channel} dropped (no legacy id)"); + return; + } + + bindings.Log?.Invoke( + $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + + $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}"); + if (!SendIfActive(() => + bindings.SendChannel(legacy.Value.ChannelId, text))) + { + return; + } + + bool serverEchoes = new ChatChannelInfo.Legacy( + legacy.Value.ChannelId, + legacy.Value.DisplayName).IsSelfEchoChannel(); + if (serverEchoes) + return; + + bindings.Chat.OnSelfSent( + ChatKind.Channel, + text, + targetOrChannel: legacy.Value.DisplayName, + logTextType: LegacyChannelChatType.Resolve( + legacy.Value.ChannelId, + ownSend: true)); + } + + private bool SendIfActive(Action send) + { + lock (_gate) + { + if (_state != 1) + return false; + send(); + return true; + } + } +} + +/// +/// Stable host-owned bus over a replaceable generation route. A retained +/// login-command runner never captures an obsolete transport. +/// +public sealed class LiveChatCommandSurface : ICommandBus +{ + private readonly object _gate = new(); + private LiveChatCommandRoute? _active; + + public ILiveSessionCommandRouting Attach(LiveChatCommandRoute route) + { + ArgumentNullException.ThrowIfNull(route); + lock (_gate) + { + if (_active is not null) + { + throw new InvalidOperationException( + "A live chat command route is already attached."); + } + _active = route; + return new RouteLease(this, route); + } + } + + public void Publish(T command) where T : notnull + { + LiveChatCommandRoute? route; + lock (_gate) + route = _active; + route?.Publish(command); + } + + private void Release(LiveChatCommandRoute expected) + { + expected.Dispose(); + lock (_gate) + { + if (ReferenceEquals(_active, expected)) + _active = null; + } + } + + private sealed class RouteLease( + LiveChatCommandSurface owner, + LiveChatCommandRoute route) + : ILiveSessionCommandRouting + { + private readonly object _gate = new(); + private LiveChatCommandSurface? _owner = owner; + + public void Activate() => route.Activate(); + + public void Dispose() + { + lock (_gate) + { + if (_owner is null) + return; + _owner.Release(route); + _owner = null; + } + } + } +} diff --git a/src/AcDream.UI.Abstractions/LiveCommandBus.cs b/src/AcDream.Runtime/Chat/LiveCommandBus.cs similarity index 98% rename from src/AcDream.UI.Abstractions/LiveCommandBus.cs rename to src/AcDream.Runtime/Chat/LiveCommandBus.cs index cb260c8e..4480dfba 100644 --- a/src/AcDream.UI.Abstractions/LiveCommandBus.cs +++ b/src/AcDream.Runtime/Chat/LiveCommandBus.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Real implementation — single-handler-per-type diff --git a/src/AcDream.Runtime/Chat/LoginCommandSequence.cs b/src/AcDream.Runtime/Chat/LoginCommandSequence.cs new file mode 100644 index 00000000..36aeb461 --- /dev/null +++ b/src/AcDream.Runtime/Chat/LoginCommandSequence.cs @@ -0,0 +1,170 @@ +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Chat; + +public readonly record struct LoginCommandFailure( + int CommandIndex, + string Command, + string Error); + +/// +/// Executes configured login lines through the same parser and command bus as +/// typed chat. A sequence is armed only by an entered-world edge, belongs to +/// one exact Runtime generation, and never lets one command or status-report +/// failure abort the remaining lines or the session. +/// +public sealed class LoginCommandSequence +{ + private readonly string[] _commands; + private readonly TimeSpan _delay; + private readonly TimeProvider _timeProvider; + private readonly IChatCommandFeedback _feedback; + private readonly ICommandBus _bus; + private readonly Action _onFailure; + private RuntimeGenerationToken _generation; + private RuntimeGenerationToken? _lastStartedGeneration; + private long _nextDeadline; + private int _nextIndex; + private bool _active; + + public LoginCommandSequence( + IEnumerable? commands, + TimeSpan delay, + IChatCommandFeedback feedback, + ICommandBus bus, + Action? onFailure = null, + TimeProvider? timeProvider = null) + { + if (delay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(delay)); + ArgumentNullException.ThrowIfNull(feedback); + ArgumentNullException.ThrowIfNull(bus); + + _commands = commands?.Select(static command => command ?? string.Empty) + .ToArray() ?? []; + _delay = delay; + _feedback = feedback; + _bus = bus; + _onFailure = onFailure ?? (static _ => { }); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public int CommandCount => _commands.Length; + public bool IsActive => _active; + public int NextCommandIndex => _nextIndex; + + /// + /// Arms this generation once and executes its first due command + /// immediately. Repeated entered-world callbacks in the same generation + /// are ignored; a reconnect generation starts the list again from zero. + /// + public void EnteredWorld(RuntimeGenerationToken generation) + { + if (_lastStartedGeneration == generation) + return; + + _lastStartedGeneration = generation; + _generation = generation; + _nextIndex = 0; + _active = _commands.Length > 0; + _nextDeadline = _timeProvider.GetTimestamp(); + DrainDue(generation, isInWorld: true); + } + + public void Tick( + RuntimeGenerationToken generation, + bool isInWorld) + { + DrainDue(generation, isInWorld); + } + + public void Cancel(RuntimeGenerationToken generation) + { + if (_active && _generation == generation) + _active = false; + } + + private void DrainDue( + RuntimeGenerationToken generation, + bool isInWorld) + { + if (!_active || !isInWorld || generation != _generation) + return; + + long now = _timeProvider.GetTimestamp(); + while (_active + && generation == _generation + && _nextIndex < _commands.Length + && now >= _nextDeadline) + { + int commandIndex = _nextIndex; + string command = _commands[commandIndex]; + try + { + SubmitOutcome outcome = ChatCommandRouter.Submit( + command, + _feedback, + _bus, + ChatChannelKind.Say); + if (outcome is SubmitOutcome.UnknownCommand + or SubmitOutcome.Dropped) + { + ReportFailure(new LoginCommandFailure( + commandIndex, + command, + $"Chat command routing returned {outcome}.")); + } + } + catch (Exception error) + { + ReportFailure(new LoginCommandFailure( + commandIndex, + command, + error.GetBaseException().Message)); + } + + // The command handler may have synchronously stopped or replaced + // the session. Cancel() then owns the state; never advance an old + // generation after returning from user-controlled code. + if (!_active || generation != _generation) + return; + + _nextIndex++; + if (_nextIndex >= _commands.Length) + { + _active = false; + return; + } + + // Inter-command delay starts after the prior handler returns. + // A slow synchronous wire/client handler must not consume the + // configured delay merely by taking time itself. + now = _timeProvider.GetTimestamp(); + _nextDeadline = Add(_timeProvider, now, _delay); + } + } + + private void ReportFailure(LoginCommandFailure failure) + { + try + { + _onFailure(failure); + } + catch (Exception) + { + // Status/diagnostic reporting observes the sequence. It can never + // poison login command execution or the session transaction. + } + } + + private static long Add( + TimeProvider provider, + long timestamp, + TimeSpan duration) + { + double delta = duration.TotalSeconds * provider.TimestampFrequency; + if (delta >= long.MaxValue - timestamp) + return long.MaxValue; + return checked(timestamp + (long)Math.Ceiling(delta)); + } +} diff --git a/src/AcDream.UI.Abstractions/NullCommandBus.cs b/src/AcDream.Runtime/Chat/NullCommandBus.cs similarity index 94% rename from src/AcDream.UI.Abstractions/NullCommandBus.cs rename to src/AcDream.Runtime/Chat/NullCommandBus.cs index 2c111ea8..23500406 100644 --- a/src/AcDream.UI.Abstractions/NullCommandBus.cs +++ b/src/AcDream.Runtime/Chat/NullCommandBus.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// No-op . Accepts any published command and diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs b/src/AcDream.Runtime/Chat/RetailChannelTagTable.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs rename to src/AcDream.Runtime/Chat/RetailChannelTagTable.cs index eb84802e..deee76fe 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs +++ b/src/AcDream.Runtime/Chat/RetailChannelTagTable.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Retail's ChannelSystem::GetChannelID @ 0x005CF1F0 — every legacy diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs rename to src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs index 6b24283c..f819526d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Immutable catalog of commands the named retail client executes locally. diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs similarity index 99% rename from src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs rename to src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs index 9c27ab27..4e07b679 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs @@ -1,7 +1,7 @@ using System.Collections.Frozen; using AcDream.Core.Chat; -namespace AcDream.UI.Abstractions.Panels.Chat; +namespace AcDream.Runtime.Chat; /// /// Campaign CH slice CH4 (2026-08-09): /help <verb> text for @@ -213,11 +213,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// /// Issue #363 (2026-08-10): ChatCommandRouter now routes this /// fallback (and every other 0x1A command-refusal call site) through -/// ChatVM.ShowInterfaceText — an optional hook the App-layer host +/// IChatCommandFeedback.ShowInterfaceText — an optional hook the host /// wires to RuntimeCommunicationState.AddText, the same SpewBox -/// chokepoint every other producer of interface text uses. UI.Abstractions -/// still never references Runtime directly (Code Structure Rules); the hook -/// is the seam. Closes ISSUES.md #367 and retires register row AP-186. +/// chokepoint every other producer of interface text uses. The retained +/// ChatVM implements this four-member feedback seam without entering +/// command-routing code. Closes ISSUES.md #367 and retires register row +/// AP-186. /// /// public static class RetailCommandHelpTable @@ -267,7 +268,7 @@ public static class RetailCommandHelpTable // DoHelp's fallback when the verb hash lookup fails, or resolves to an // entry with no registered help callback. Retail types this 0x1A // (ClientLocal) -- SpewBox-only; see the class remarks' routing note -- - // ChatCommandRouter now routes it through ChatVM.ShowInterfaceText + // ChatCommandRouter routes it through IChatCommandFeedback.ShowInterfaceText // (issue #363), closing #367. public const string UnknownCommand = "Unknown command"; diff --git a/src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs b/src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs new file mode 100644 index 00000000..f6e82fa7 --- /dev/null +++ b/src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs @@ -0,0 +1,32 @@ +using AcDream.Core.Chat; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Chat; + +/// +/// Presentation-free feedback for chat commands executed by a host rather +/// than a panel. It borrows the canonical communication owner; it creates no +/// transcript or reply-target mirror. +/// +public sealed class RuntimeChatCommandFeedback : IChatCommandFeedback +{ + private readonly RuntimeCommunicationState _communication; + + public RuntimeChatCommandFeedback(RuntimeCommunicationState communication) + { + _communication = communication + ?? throw new ArgumentNullException(nameof(communication)); + } + + public string? LastIncomingTellSender => + _communication.CommandTargets.LastIncomingTellSender; + + public string? LastOutgoingTellTarget => + _communication.CommandTargets.LastOutgoingTellTarget; + + public void ShowInterfaceText(string text) => + _communication.AddText(text, RetailLogTextType.ClientLocal); + + public void ShowSystemMessage(string text) => + _communication.Chat.OnSystemMessage(text, chatType: 0x00u); +} diff --git a/src/AcDream.UI.Abstractions/SendChatCmd.cs b/src/AcDream.Runtime/Chat/SendChatCmd.cs similarity index 93% rename from src/AcDream.UI.Abstractions/SendChatCmd.cs rename to src/AcDream.Runtime/Chat/SendChatCmd.cs index 6b5d9501..7e76f421 100644 --- a/src/AcDream.UI.Abstractions/SendChatCmd.cs +++ b/src/AcDream.Runtime/Chat/SendChatCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Command published by chat panels to send a message. The host resolves diff --git a/src/AcDream.UI.Abstractions/SendRawChannelCmd.cs b/src/AcDream.Runtime/Chat/SendRawChannelCmd.cs similarity index 86% rename from src/AcDream.UI.Abstractions/SendRawChannelCmd.cs rename to src/AcDream.Runtime/Chat/SendRawChannelCmd.cs index 1aaea51e..4454fa3b 100644 --- a/src/AcDream.UI.Abstractions/SendRawChannelCmd.cs +++ b/src/AcDream.Runtime/Chat/SendRawChannelCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy ChatChannel @@ -11,7 +11,7 @@ namespace AcDream.UI.Abstractions; /// (GM/faction channels like @admin, @sentinel, /// @celestialhand) plus the argument channel-tag resolution /// @clist/@on/@off use. See -/// for the tag→id table. +/// for the tag→id table. /// /// public sealed record SendRawChannelCmd(uint ChannelId, string Text); diff --git a/src/AcDream.UI.Abstractions/SendServerCommandCmd.cs b/src/AcDream.Runtime/Chat/SendServerCommandCmd.cs similarity index 89% rename from src/AcDream.UI.Abstractions/SendServerCommandCmd.cs rename to src/AcDream.Runtime/Chat/SendServerCommandCmd.cs index b53aa807..cea17173 100644 --- a/src/AcDream.UI.Abstractions/SendServerCommandCmd.cs +++ b/src/AcDream.Runtime/Chat/SendServerCommandCmd.cs @@ -1,4 +1,4 @@ -namespace AcDream.UI.Abstractions; +namespace AcDream.Runtime.Chat; /// /// Command text owned by the connected server rather than the retail client. diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 62cf5441..1782f9d4 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -1,5 +1,6 @@ using System.Runtime.ExceptionServices; using AcDream.Core.Net; +using AcDream.Runtime.Chat; namespace AcDream.Runtime.Session; @@ -39,7 +40,8 @@ public sealed record LiveSessionHostBindings( /// 's narrow SetActiveCharacter(string) /// fan-out, this exists so a status writer can emit the /// enteredWorld event's characterId field. - Action CharacterEntered); + Action CharacterEntered, + LoginCommandSequence? LoginCommands = null); /// /// Runtime host for the one canonical . @@ -47,7 +49,9 @@ public sealed record LiveSessionHostBindings( /// composition, but never mirrors session, generation, identity, routing, or /// command state. /// -public sealed class LiveSessionHost : IRuntimeSessionCommands +public sealed class LiveSessionHost + : IRuntimeSessionCommands, + IRuntimeLiveSessionFramePhase { private sealed class PendingRouteRollback( ILiveSessionCommandRouting? commands, @@ -98,6 +102,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands private readonly Action _characterEntered; private readonly Action _reset; private readonly LiveSessionLifecycleHost _lifecycle; + private readonly LoginCommandSequence? _loginCommands; private PendingRouteRollback? _pendingRouteRollback; public LiveSessionHost( @@ -114,6 +119,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands ?? throw new ArgumentNullException(nameof(bindings.EnteredWorld)); _characterEntered = bindings.CharacterEntered ?? throw new ArgumentNullException(nameof(bindings.CharacterEntered)); + _loginCommands = bindings.LoginCommands; ArgumentNullException.ThrowIfNull(_routing.CreateEvents); ArgumentNullException.ThrowIfNull(_routing.CreateCommands); ArgumentNullException.ThrowIfNull(bindings.Reset); @@ -176,6 +182,17 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands RuntimeGenerationToken expectedGeneration) => _controller.Stop(expectedGeneration); + /// + /// Pumps the canonical network session first, then any due login command. + /// Both graphical and headless frame loops use this host boundary so the + /// sequencing contract cannot drift between them. + /// + public void Tick() + { + _controller.Tick(); + _loginCommands?.Tick(_controller.Generation, _controller.IsInWorld); + } + private LiveSessionBinding BindSession(WorldSession session) { DrainPendingRouteRollback(); @@ -211,6 +228,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands // state below. Treat physical route convergence as the same hard // barrier used by normal LiveSessionBinding teardown. DrainPendingRouteRollback(); + _loginCommands?.Cancel(retiringGeneration); _reset(retiringGeneration); } @@ -234,6 +252,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands _enteredWorld.LoadCharacterSettings(name); _enteredWorld.ArmPlayerModeAutoEntry(); _characterEntered(selection); + _loginCommands?.EnteredWorld(_controller.Generation); } private void RethrowWithRetryableRollback( diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index 2b3461fa..c5a01d95 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -228,6 +228,22 @@ public sealed class SessionStatusWriter error, }); + public void LoginCommandFailed( + string sessionId, + int commandIndex, + string command, + string error) => + Write(new + { + v = VocabularyVersion, + e = "loginCommandFailed", + t = Now(), + sessionId, + commandIndex, + command, + error, + }); + public void Disconnected(string sessionId, string reason) { if (!IsEnabled) diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj index 2f4ca833..79dc2e2c 100644 --- a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj +++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj @@ -11,5 +11,6 @@ + diff --git a/src/AcDream.UI.Abstractions/GlobalUsings.cs b/src/AcDream.UI.Abstractions/GlobalUsings.cs new file mode 100644 index 00000000..8a503f36 --- /dev/null +++ b/src/AcDream.UI.Abstractions/GlobalUsings.cs @@ -0,0 +1 @@ +global using AcDream.Runtime.Chat; diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 02a71e50..09adc96e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -21,7 +21,7 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// unchanged transcript does not require a queue snapshot each frame. /// /// -public sealed class ChatVM : IDisposable +public sealed class ChatVM : IDisposable, IChatCommandFeedback { /// Default number of tail entries rendered. public const int DefaultDisplayLimit = 20; diff --git a/tests/AcDream.App.Tests/GlobalUsings.cs b/tests/AcDream.App.Tests/GlobalUsings.cs index 5213b6cc..83a9ab4d 100644 --- a/tests/AcDream.App.Tests/GlobalUsings.cs +++ b/tests/AcDream.App.Tests/GlobalUsings.cs @@ -1,4 +1,5 @@ global using AcDream.Runtime.Gameplay; global using AcDream.Runtime.Physics; +global using AcDream.Runtime.Chat; global using ILocalPlayerMotionSource = AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource; diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index c0cf97fc..2812f7bf 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -6,12 +6,54 @@ using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Social; using AcDream.Runtime.Gameplay; +using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; namespace AcDream.App.Tests.Net; public sealed class LiveSessionCommandRouterTests { + [Fact] + public void LoginCommandSequenceUsesTheIdenticalGraphicalCommandSurface() + { + using var communication = new RuntimeCommunicationState(); + var calls = new List(); + ClientCommandController.Bindings client = NewClientBindings() with + { + QueryAge = () => calls.Add("client:age"), + }; + var router = NewRouter( + chat: communication.Chat, + turbine: communication.TurbineChat, + communication: communication, + clientBindings: client, + sendTalk: text => calls.Add($"talk:{text}"), + sendTell: (target, text) => + calls.Add($"tell:{target}:{text}"), + sendChannel: (channel, text) => + calls.Add($"channel:{channel:X8}:{text}")); + var surface = new LiveSessionCommandSurface(); + using ILiveSessionCommandRouting lease = surface.Attach(router); + lease.Activate(); + var sequence = new LoginCommandSequence( + ["hello", "/tell Bob, secret", "@admin raw", "/age"], + TimeSpan.Zero, + new RuntimeChatCommandFeedback(communication), + surface); + + sequence.EnteredWorld(new RuntimeGenerationToken(9)); + + Assert.Equal( + [ + "talk:hello", + "tell:Bob:secret", + "channel:00000002:raw", + "client:age", + ], + calls); + } + [Fact] public void InactiveAndDisposedRouter_CannotReachTransport() { diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs index c2f332ef..44ace1c9 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs @@ -31,7 +31,11 @@ public sealed class HeadlessPluginSessionTests string statusPath = Path.Combine(temporary.Path, "status.jsonl"); var credential = new HeadlessCredentialSecret("fixture", "password"); using var session = new HeadlessSessionHost( - Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath), + Descriptor( + [FixtureId.ToUpperInvariant(), BrokenId], + statusPath, + loginCommands: ["/"], + loginCommandDelayMs: 0), credential, diagnostics, new FixtureSessionOperations(), @@ -57,7 +61,7 @@ public sealed class HeadlessPluginSessionTests Assert.Equal( [ "started", "pluginLoaded", "pluginFailed", "connected", - "characterList", "enteredWorld", + "characterList", "enteredWorld", "loginCommandFailed", ], EventNames(statuses)); Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString()); @@ -66,6 +70,8 @@ public sealed class HeadlessPluginSessionTests "entry dll not found", statuses[2].GetProperty("error").GetString()!, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, statuses[6].GetProperty("commandIndex").GetInt32()); + Assert.Equal("/", statuses[6].GetProperty("command").GetString()); Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString()); WeakReference context = Assert.Single( @@ -231,7 +237,9 @@ public sealed class HeadlessPluginSessionTests private static HeadlessSessionDescriptor Descriptor( List plugins, - string statusPath) => new() + string statusPath, + IReadOnlyList? loginCommands = null, + int loginCommandDelayMs = 500) => new() { Id = "headless-session", Endpoint = new HeadlessEndpointDescriptor @@ -255,6 +263,8 @@ public sealed class HeadlessPluginSessionTests }, Plugins = plugins, StatusFile = statusPath, + LoginCommands = loginCommands is null ? null : [.. loginCommands], + LoginCommandDelayMs = loginCommandDelayMs, }; private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new( diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 1834cb35..8dabeb98 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -24,6 +24,165 @@ namespace AcDream.Headless.Tests; public sealed class HeadlessSessionHostTests { + [Fact] + public void LoginCommandsUseTheHeadlessLiveBusAndPreserveWireOrder() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor( + loginCommands: + [ + "hello", + "/tell Bob, secret", + "/f group", + "@admin raw", + "/vt start", + ], + loginCommandDelayMs: 0), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + RuntimeSessionStartResult result = host.Start(); + + Assert.Equal(RuntimeSessionStartStatus.Connected, result.Status); + Assert.Equal( + [ + ChatRequests.TalkOpcode, + ChatRequests.TellOpcode, + ChatRequests.ChatChannelOpcode, + ChatRequests.ChatChannelOpcode, + ChatRequests.TalkOpcode, + ], + captured.Select(ActionOpcode)); + Assert.Equal( + 0x00000800u, + BinaryPrimitives.ReadUInt32LittleEndian(captured[2].AsSpan(12))); + Assert.Equal( + 0x00000002u, + BinaryPrimitives.ReadUInt32LittleEndian(captured[3].AsSpan(12))); + } + + [Fact] + public void LoginCommandFailuresAreVersionedOrderedAndSessionIsolated() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-login-commands-{Guid.NewGuid():N}.jsonl"); + try + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor( + statusFile: statusPath, + loginCommands: ["/", "/version", "after"], + loginCommandDelayMs: 0), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + RuntimeSessionStartResult result = host.Start(); + + Assert.Equal(RuntimeSessionStartStatus.Connected, result.Status); + Assert.True(host.Runtime.Session.IsInWorld); + Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(captured[0])); + + JsonElement[] events = File.ReadAllLines(statusPath) + .Select(static line => + JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + Assert.Equal( + [ + "started", "connected", "characterList", "enteredWorld", + "loginCommandFailed", "loginCommandFailed", + ], + events.Select(static item => + item.GetProperty("e").GetString())); + JsonElement[] failures = events + .Where(static item => + item.GetProperty("e").GetString() + == "loginCommandFailed") + .ToArray(); + Assert.Equal(1, failures[0].GetProperty("v").GetInt32()); + Assert.Equal(0, failures[0].GetProperty("commandIndex").GetInt32()); + Assert.Equal("/", failures[0].GetProperty("command").GetString()); + Assert.Equal( + "Chat command routing returned UnknownCommand.", + failures[0].GetProperty("error").GetString()); + Assert.Equal(1, failures[1].GetProperty("commandIndex").GetInt32()); + Assert.Equal( + "/version", + failures[1].GetProperty("command").GetString()); + Assert.Contains( + "not available in the headless host", + failures[1].GetProperty("error").GetString()); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + + [Fact] + public void LoginCommandDelayIsGenerationScopedAcrossReconnect() + { + var time = new ManualTimeProvider(); + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor( + loginCommands: ["first", "second"], + loginCommandDelayMs: 500), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations, + timeProvider: time); + + Assert.Equal(RuntimeSessionStartStatus.Connected, host.Start().Status); + Assert.Equal(["first"], captured.Select(TalkText)); + + host.Tick(0.1d); + Assert.Equal(["first"], captured.Select(TalkText)); + + // Replacement cancels the retiring generation's pending "second" + // and starts the configured list once for the new entered-world edge. + Assert.Equal(RuntimeSessionStartStatus.Connected, host.Reconnect().Status); + Assert.Equal(["first", "first"], captured.Select(TalkText)); + + time.Advance(TimeSpan.FromMilliseconds(499)); + host.Tick(0.1d); + Assert.Equal(["first", "first"], captured.Select(TalkText)); + + time.Advance(TimeSpan.FromMilliseconds(1)); + host.Tick(0.1d); + Assert.Equal(["first", "first", "second"], captured.Select(TalkText)); + } + [Fact] public void SingleSessionStartsReconnectsAndConvergesWithoutPresentation() { @@ -2452,7 +2611,9 @@ public sealed class HeadlessSessionHostTests HeadlessCredentialProviderKind.Environment, string credentialReference = "BOT_PASSWORD", Dictionary? characterOptions = null, - string? statusFile = null) => new() + string? statusFile = null, + IReadOnlyList? loginCommands = null, + int loginCommandDelayMs = 500) => new() { Id = "bot", Endpoint = new HeadlessEndpointDescriptor @@ -2476,6 +2637,8 @@ public sealed class HeadlessSessionHostTests }, CharacterOptions = characterOptions, StatusFile = statusFile, + LoginCommands = loginCommands is null ? null : [.. loginCommands], + LoginCommandDelayMs = loginCommandDelayMs, }; /// Campaign LA slice LA2: a probe-mode descriptor — mode @@ -3115,6 +3278,26 @@ public sealed class HeadlessSessionHostTests BinaryPrimitives.ReadUInt32LittleEndian( body.AsSpan(8, sizeof(uint))); + private static string TalkText(byte[] body) + { + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + ushort length = BinaryPrimitives.ReadUInt16LittleEndian( + body.AsSpan(12, sizeof(ushort))); + return System.Text.Encoding.ASCII.GetString(body, 14, length); + } + + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => _timestamp; + + internal void Advance(TimeSpan duration) => + _timestamp = checked(_timestamp + duration.Ticks); + } + // SF-4 fixture: minimal PlayerDescription (0x0013) body carrying only // the CharacterOptions1/2 trailer fields — copied from // HeadlessCharacterOptionsSeederWiringTests.WrapPlayerDescriptionEnvelope @@ -3162,6 +3345,7 @@ public sealed class HeadlessSessionHostTests public int DisposedSessionCount { get; private set; } public string? LastUser { get; private set; } public string? LastPassword { get; private set; } + public Action? GameActionCapture { get; init; } public int EnterWorldCallCount => Volatile.Read(ref _enterWorldCallCount); public int TickCallCount => Volatile.Read(ref _tickCallCount); @@ -3190,6 +3374,7 @@ public sealed class HeadlessSessionHostTests { CreatedSessionCount++; var session = new WorldSession(endpoint); + session.GameActionCapture = GameActionCapture; Sessions.Add(session); return session; } diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs index 57ab95ed..54cb66d2 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs @@ -106,6 +106,44 @@ public sealed class LauncherOrchestratorTests : IDisposable Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal); } + [Fact] + public async Task LoginCommandFailureIsVisibleButDoesNotMakeTheSessionTerminal() + { + var statusSources = new QueueStatusSourceFactory(); + using LauncherOrchestrator orchestrator = CreateOrchestrator( + statusSourceFactory: statusSources); + _ = await orchestrator.LaunchAsync( + "Local ACE", + "testaccount", + "+Acdream", + LaunchMode.Headless); + + QueueStatusSource source = Assert.Single(statusSources.Created); + source.Enqueue(Connected("s1")); + source.Enqueue(EnteredWorld("s1", "+Acdream")); + source.Enqueue(new LoginCommandFailedStatusEvent + { + V = 1, + E = "loginCommandFailed", + T = DateTimeOffset.UtcNow, + SessionId = "s1", + CommandIndex = 2, + Command = "/version", + Error = "not available in the headless host", + }); + + orchestrator.PollStatus(); + + LauncherSessionSnapshot session = Assert.Single( + orchestrator.GetSnapshot().Sessions); + Assert.Equal(LauncherActivityState.InWorld, session.State); + Assert.Equal( + "Login command 2 failed: not available in the headless host", + session.Error); + Assert.Equal(session.Error, session.Status); + Assert.Null(session.ExitCode); + } + [Fact] public async Task AccountGuiSelectDoesNotRequireACachedCharacterOrEmitASelector() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index 47f5e118..2055ae92 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -77,6 +77,32 @@ public sealed class StatusEventParserTests Assert.Equal("boom", failed.Error); } + [Fact] + public void ParsesLoginCommandFailed() + { + var failed = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"loginCommandFailed","t":"2026-08-14T12:00:05Z","sessionId":"s1","commandIndex":2,"command":"/version","error":"unsupported headless command"}""")); + + Assert.Equal(2, failed.CommandIndex); + Assert.Equal("/version", failed.Command); + Assert.Equal("unsupported headless command", failed.Error); + } + + [Theory] + [InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":-1,\"command\":\"/version\",\"error\":\"unsupported\"}")] + [InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":0,\"error\":\"unsupported\"}")] + [InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":0,\"command\":\"/version\"}")] + public void MalformedLoginCommandFailureUsesTheKnownEventFailurePath(string line) + { + var malformed = Assert.IsType( + StatusEventParser.Parse(line)); + + Assert.Equal("loginCommandFailed", malformed.E); + Assert.Equal("s1", malformed.SessionId); + Assert.False(string.IsNullOrWhiteSpace(malformed.Error)); + } + [Fact] public void ParsesDisconnectedAndExited() { diff --git a/tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs b/tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs new file mode 100644 index 00000000..b4d391f6 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs @@ -0,0 +1,60 @@ +using System.Reflection; +using AcDream.Runtime.Chat; + +namespace AcDream.Runtime.Tests.Chat; + +public sealed class ChatExtractionTests +{ + [Fact] + public void CompleteChatCoreLivesInRuntimeAssembly() + { + Assembly runtime = typeof(GameRuntime).Assembly; + Type[] extracted = + [ + typeof(ChatInputParser), + typeof(ChatCommandRouter), + typeof(RetailClientCommandCatalog), + typeof(RetailCommandHelpTable), + typeof(RetailChannelTagTable), + typeof(ChannelResolver), + typeof(ICommandBus), + typeof(LiveCommandBus), + typeof(NullCommandBus), + typeof(ChatChannelKind), + typeof(ClientCommandId), + typeof(SendChatCmd), + typeof(SendServerCommandCmd), + typeof(SendRawChannelCmd), + typeof(ExecuteClientCommandCmd), + ]; + + Assert.All(extracted, type => Assert.Same(runtime, type.Assembly)); + Assert.DoesNotContain( + runtime.GetReferencedAssemblies(), + reference => reference.Name is "AcDream.App" + or "AcDream.UI.Abstractions"); + } + + [Fact] + public void RouterDependsOnlyOnTheFourMemberFeedbackContract() + { + string[] members = typeof(IChatCommandFeedback) + .GetMembers(BindingFlags.Instance | BindingFlags.Public) + .Where(static member => member.MemberType is + MemberTypes.Method or MemberTypes.Property) + .Where(static member => member is not MethodInfo method + || !method.IsSpecialName) + .Select(static member => member.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + "LastIncomingTellSender", + "LastOutgoingTellTarget", + "ShowInterfaceText", + "ShowSystemMessage", + ], + members); + } +} diff --git a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs new file mode 100644 index 00000000..04d8fa79 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs @@ -0,0 +1,59 @@ +using AcDream.Core.Chat; +using AcDream.Runtime.Chat; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Chat; + +public sealed class LiveChatCommandRouteTests +{ + [Fact] + public void FourRegistrationsPreserveOrderedWireRoutesAndCanonicalEcho() + { + using var communication = new RuntimeCommunicationState(); + using var character = new RuntimeCharacterState(); + var sent = new List(); + var route = new LiveChatCommandRoute(new LiveChatCommandBindings( + command => sent.Add($"client:{command.Command}"), + communication, + communication.Chat, + communication.TurbineChat, + character, + () => 0x50000001u, + text => sent.Add($"talk:{text}"), + (target, text) => sent.Add($"tell:{target}:{text}"), + (channel, text) => sent.Add($"channel:{channel:X8}:{text}"), + (_, _, _, _, text, _) => sent.Add($"turbine:{text}"))); + + route.Activate(); + route.Publish(new SendServerCommandCmd("@server")); + route.Publish(new SendChatCmd(ChatChannelKind.Say, null, "say")); + route.Publish(new SendChatCmd(ChatChannelKind.Tell, "Bob", "secret")); + route.Publish(new SendChatCmd( + ChatChannelKind.Fellowship, + null, + "group")); + route.Publish(new SendRawChannelCmd(0x00000002u, "admin")); + route.Publish(new ExecuteClientCommandCmd( + ClientCommandId.QueryAge, + string.Empty)); + + Assert.Equal( + [ + "talk:@server", + "talk:say", + "tell:Bob:secret", + "channel:00000800:group", + "channel:00000002:admin", + "client:QueryAge", + ], + sent); + ChatEntry echo = Assert.Single(communication.Chat.Snapshot()); + Assert.Equal(ChatKind.Tell, echo.Kind); + Assert.Equal("Bob", echo.Sender); + Assert.Equal("secret", echo.Text); + + route.Dispose(); + route.Publish(new SendServerCommandCmd("@stale")); + Assert.Equal(6, sent.Count); + } +} diff --git a/tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs b/tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs new file mode 100644 index 00000000..9ff7a7e8 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs @@ -0,0 +1,184 @@ +using AcDream.Runtime.Chat; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Chat; + +public sealed class LoginCommandSequenceTests +{ + [Fact] + public void EnteredWorldExecutesImmediatelyThenHonorsMonotonicDelay() + { + var time = new ManualTimeProvider(); + var sent = new List(); + var bus = TalkBus(sent); + var sequence = new LoginCommandSequence( + ["one", "two", "three"], + TimeSpan.FromMilliseconds(500), + new RecordingFeedback(), + bus, + timeProvider: time); + var generation = new RuntimeGenerationToken(7); + + sequence.EnteredWorld(generation); + Assert.Equal(["one"], sent); + + time.Advance(TimeSpan.FromMilliseconds(499)); + sequence.Tick(generation, isInWorld: true); + Assert.Equal(["one"], sent); + + time.Advance(TimeSpan.FromMilliseconds(1)); + sequence.Tick(generation, isInWorld: true); + Assert.Equal(["one", "two"], sent); + + time.Advance(TimeSpan.FromMilliseconds(500)); + sequence.Tick(generation, isInWorld: true); + Assert.Equal(["one", "two", "three"], sent); + Assert.False(sequence.IsActive); + } + + [Fact] + public void HandlerRuntimeDoesNotConsumeTheInterCommandDelay() + { + var time = new ManualTimeProvider(); + var sent = new List(); + var bus = new LiveCommandBus(); + bus.Register(command => + { + sent.Add(command.Text); + time.Advance(TimeSpan.FromSeconds(1)); + }); + var sequence = new LoginCommandSequence( + ["one", "two"], + TimeSpan.FromMilliseconds(500), + new RecordingFeedback(), + bus, + timeProvider: time); + var generation = new RuntimeGenerationToken(7); + + sequence.EnteredWorld(generation); + Assert.Equal(["one"], sent); + + time.Advance(TimeSpan.FromMilliseconds(499)); + sequence.Tick(generation, isInWorld: true); + Assert.Equal(["one"], sent); + + time.Advance(TimeSpan.FromMilliseconds(1)); + sequence.Tick(generation, isInWorld: true); + Assert.Equal(["one", "two"], sent); + } + + [Fact] + public void ParseAndHandlerFailuresAndStatusFailureAllContinue() + { + var sent = new List(); + var bus = TalkBus(sent); + bus.Register(_ => + throw new InvalidOperationException("client boom")); + int reports = 0; + var sequence = new LoginCommandSequence( + ["/", "/version", "after"], + TimeSpan.Zero, + new RecordingFeedback(), + bus, + _ => + { + reports++; + throw new IOException("status unavailable"); + }); + + sequence.EnteredWorld(new RuntimeGenerationToken(1)); + + Assert.Equal(2, reports); + Assert.Equal(["after"], sent); + Assert.False(sequence.IsActive); + } + + [Fact] + public void CancelAndGenerationReplacementPreventLeaksAndReconnectRestarts() + { + var time = new ManualTimeProvider(); + var sent = new List(); + var sequence = new LoginCommandSequence( + ["one", "two"], + TimeSpan.FromMilliseconds(500), + new RecordingFeedback(), + TalkBus(sent), + timeProvider: time); + var first = new RuntimeGenerationToken(1); + var second = new RuntimeGenerationToken(2); + + sequence.EnteredWorld(first); + sequence.Cancel(first); + time.Advance(TimeSpan.FromSeconds(1)); + sequence.Tick(first, isInWorld: true); + sequence.EnteredWorld(first); // exact-once per generation + Assert.Equal(["one"], sent); + + sequence.EnteredWorld(second); + sequence.Tick(first, isInWorld: true); // stale frame is inert + time.Advance(TimeSpan.FromMilliseconds(500)); + sequence.Tick(second, isInWorld: true); + + Assert.Equal(["one", "one", "two"], sent); + } + + [Fact] + public void NullAndEmptyCollectionsAreNoOpsAndNullEntriesTypeAsEmpty() + { + var feedback = new RecordingFeedback(); + var bus = TalkBus([]); + var absent = new LoginCommandSequence( + null, + TimeSpan.Zero, + feedback, + bus); + var empty = new LoginCommandSequence( + [], + TimeSpan.Zero, + feedback, + bus); + var nullEntry = new LoginCommandSequence( + [null], + TimeSpan.Zero, + feedback, + bus); + + absent.EnteredWorld(new RuntimeGenerationToken(1)); + empty.EnteredWorld(new RuntimeGenerationToken(1)); + nullEntry.EnteredWorld(new RuntimeGenerationToken(1)); + + Assert.False(absent.IsActive); + Assert.False(empty.IsActive); + Assert.False(nullEntry.IsActive); + } + + private static LiveCommandBus TalkBus(List sent) + { + var bus = new LiveCommandBus(); + bus.Register(command => sent.Add(command.Text)); + bus.Register(command => sent.Add(command.Text)); + bus.Register(_ => { }); + return bus; + } + + private sealed class RecordingFeedback : IChatCommandFeedback + { + public string? LastIncomingTellSender { get; set; } + public string? LastOutgoingTellTarget { get; set; } + public List Interface { get; } = []; + public List System { get; } = []; + public void ShowInterfaceText(string text) => Interface.Add(text); + public void ShowSystemMessage(string text) => System.Add(text); + } + + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + public override long TimestampFrequency => 1_000; + public override long GetTimestamp() => _timestamp; + + public void Advance(TimeSpan duration) => + _timestamp += checked((long)duration.TotalMilliseconds); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs index 31373a74..cda56c90 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs @@ -1,6 +1,7 @@ using System.Net; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Chat; using AcDream.Runtime.Session; namespace AcDream.Runtime.Tests.Session; @@ -74,6 +75,37 @@ public sealed class LiveSessionHostTests Assert.False(host.IsInWorld); } + [Fact] + public void LoginSequenceStartsAfterTheEnteredWorldObservation() + { + var calls = new List(); + var operations = new TestOperations(calls); + var controller = new LiveSessionController(operations); + var bus = new LiveCommandBus(); + bus.Register(command => calls.Add($"login:{command.Text}")); + var loginCommands = new LoginCommandSequence( + ["ready"], + TimeSpan.Zero, + new TestFeedback(), + bus); + LiveSessionHost host = CreateHost( + controller, + calls, + _ => new TestEventRouting(calls), + _ => new TestCommandRouting(calls), + loginCommands); + + Assert.Equal( + LiveSessionStartStatus.Connected, + host.Start(LiveOptions()).Status); + + int entered = calls.IndexOf("character-entered:1342177282"); + int login = calls.IndexOf("login:ready"); + Assert.True(entered >= 0); + Assert.Equal(entered + 1, login); + controller.Dispose(); + } + [Fact] public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute() { @@ -219,7 +251,8 @@ public sealed class LiveSessionHostTests LiveSessionController controller, List calls, Func createEvents, - Func createCommands) => + Func createCommands, + LoginCommandSequence? loginCommands = null) => new(controller, new LiveSessionHostBindings( Routing: new(createEvents, createCommands), Reset: _ => calls.Add("reset"), @@ -240,7 +273,8 @@ public sealed class LiveSessionHostTests Connected: () => calls.Add("connected"), Roster: roster => calls.Add($"roster:{roster.AccountName}"), CharacterEntered: selection => - calls.Add($"character-entered:{selection.CharacterId}"))); + calls.Add($"character-entered:{selection.CharacterId}"), + LoginCommands: loginCommands)); private static LiveSessionConnectOptions LiveOptions( bool live = true, @@ -291,6 +325,14 @@ public sealed class LiveSessionHostTests public void Dispose() => calls.Add("dispose-commands"); } + private sealed class TestFeedback : IChatCommandFeedback + { + public string? LastIncomingTellSender => null; + public string? LastOutgoingTellTarget => null; + public void ShowInterfaceText(string text) { } + public void ShowSystemMessage(string text) { } + } + private sealed class TestOperations(List calls) : ILiveSessionOperations { public List Sessions { get; } = []; diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index f9208079..95e37ff8 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -32,11 +32,12 @@ public sealed class SessionStatusWriterTests writer.EnteredWorld("s1", 0x50000001u, "Ready"); writer.PluginLoaded("s1", "acdream.good"); writer.PluginFailed("s1", "acdream.bad", "enable failed"); + writer.LoginCommandFailed("s1", 2, "/version", "unsupported headless command"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); - Assert.Equal(8, lines.Length); + Assert.Equal(9, lines.Length); JsonElement started = Parse(lines[0]); Assert.Equal(1, started.GetProperty("v").GetInt32()); @@ -73,11 +74,25 @@ public sealed class SessionStatusWriterTests Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString()); Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString()); - JsonElement disconnected = Parse(lines[6]); + JsonElement loginCommandFailed = Parse(lines[6]); + Assert.Equal(1, loginCommandFailed.GetProperty("v").GetInt32()); + Assert.Equal("loginCommandFailed", loginCommandFailed.GetProperty("e").GetString()); + Assert.Equal("s1", loginCommandFailed.GetProperty("sessionId").GetString()); + Assert.Equal(2, loginCommandFailed.GetProperty("commandIndex").GetInt32()); + Assert.Equal("/version", loginCommandFailed.GetProperty("command").GetString()); + Assert.Equal( + "unsupported headless command", + loginCommandFailed.GetProperty("error").GetString()); + Assert.Equal( + ["v", "e", "t", "sessionId", "commandIndex", "command", "error"], + loginCommandFailed.EnumerateObject() + .Select(static property => property.Name)); + + JsonElement disconnected = Parse(lines[7]); Assert.Equal("disconnected", disconnected.GetProperty("e").GetString()); Assert.Equal("stopped", disconnected.GetProperty("reason").GetString()); - JsonElement exited = Parse(lines[7]); + JsonElement exited = Parse(lines[8]); Assert.Equal("exited", exited.GetProperty("e").GetString()); Assert.Equal(0, exited.GetProperty("code").GetInt32()); Assert.Equal("disposed", exited.GetProperty("reason").GetString()); @@ -93,6 +108,7 @@ public sealed class SessionStatusWriterTests writer.Connected("s1"); writer.PluginLoaded("s1", "acdream.good"); writer.PluginFailed("s1", "acdream.bad", "failed"); + writer.LoginCommandFailed("s1", 0, "", "unknown command"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); @@ -199,11 +215,12 @@ public sealed class SessionStatusWriterTests writer.EnteredWorld("bot", 0x50000001u, "Ready"); writer.PluginLoaded("bot", "acdream.good"); writer.PluginFailed("bot", "acdream.bad", "enable failed"); + writer.LoginCommandFailed("bot", 1, "/version", "unsupported"); writer.Disconnected("bot", "stopped"); writer.Exited("bot", 0, "disposed"); string[] lines = File.ReadAllLines(file.Path); - Assert.Equal(8, lines.Length); + Assert.Equal(9, lines.Length); AssertExactProperties(lines[0], "v", "e", "t", "sessionId"); AssertExactProperties(lines[1], "v", "e", "t", "sessionId"); @@ -215,8 +232,11 @@ public sealed class SessionStatusWriterTests AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin"); AssertExactProperties( lines[5], "v", "e", "t", "sessionId", "plugin", "error"); - AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason"); - AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason"); + AssertExactProperties( + lines[6], + "v", "e", "t", "sessionId", "commandIndex", "command", "error"); + AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "reason"); + AssertExactProperties(lines[8], "v", "e", "t", "sessionId", "code", "reason"); // The nested characters[] entries are exact too — the exact shape a // password could otherwise be smuggled through. diff --git a/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj index f99c6e22..3d1f7e7e 100644 --- a/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj +++ b/tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs index a09b6331..95f7df1a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs @@ -14,7 +14,7 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat; /// public sealed class ChatPanelFocusTests { - private sealed class NullBus : AcDream.UI.Abstractions.ICommandBus + private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus { public void Publish(T command) where T : notnull { } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 8e3a95be..77aac3f7 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -47,8 +47,8 @@ public sealed class ChatPanelInputTests var entries = log.Snapshot(); Assert.Equal(2, entries.Length); Assert.All(entries, entry => Assert.Equal(ChatKind.System, entry.Kind)); - Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.HelpPrefixNote, entries[0].Text); - Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.AvailableHelpListing, entries[1].Text); + Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text); + Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text); } [Theory] From 6cfab727f130422f9247a5ca128a900bf64aacfe Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 20:29:19 +0200 Subject: [PATCH 041/138] Implement retail character management screen --- docs/architecture/acdream-architecture.md | 10 + .../2026-08-14-la8-character-management-ui.md | 104 +++ .../InteractionRetainedUiComposition.cs | 15 +- .../InteractionUiRuntimeSources.cs | 48 ++ .../Layout/CharacterManagementUiController.cs | 578 ++++++++++++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 2 + .../RetailConfirmationTextInputDialogView.cs | 160 ++++ src/AcDream.App/UI/Layout/RetailDialogData.cs | 20 + .../UI/Layout/RetailDialogFactory.cs | 33 +- .../UI/Layout/RetailMessageDialogView.cs | 105 +++ src/AcDream.App/UI/RetailUiRuntime.cs | 167 +++- src/AcDream.App/UI/UiButton.cs | 12 + .../InteractionUiRuntimeSourcesTests.cs | 112 ++- .../SessionPlayerCompositionTests.cs | 21 +- .../Layout/CharacterManagementLiveDatTests.cs | 164 ++++ .../CharacterManagementUiControllerTests.cs | 716 ++++++++++++++++++ .../UI/Layout/DatWidgetFactoryTests.cs | 2 + .../UI/Layout/RetailDialogFactoryTests.cs | 149 ++++ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 23 + 19 files changed, 2435 insertions(+), 6 deletions(-) create mode 100644 docs/research/2026-08-14-la8-character-management-ui.md create mode 100644 src/AcDream.App/UI/Layout/CharacterManagementUiController.cs create mode 100644 src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs create mode 100644 src/AcDream.App/UI/Layout/RetailMessageDialogView.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 31ec860b..99ee94a8 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -157,6 +157,16 @@ window registration, plugin mounts, cursor feedback, layout persistence, and the retained tick/draw/restore/dispose paths. Panel-specific construction must not move back into `GameWindow.OnLoad`. +The graphical no-selector launch projects Runtime's sole +`RuntimeCharacterSelectionState` through the retained character-management root +resolved from DAT enum table 5 (`0x10000005` -> `0x21000004`, selected root +`0x1000039A`). App borrows the view and routes generation-capturing typed +commands; it owns no roster, highlight, operation, error, or lifecycle mirror. +The authored screen is a flat ListBox and buttons, with the shared retail dialog +catalog for confirmation, wait, and error presentation. It contains no viewport +or character preview. Explicit-selector graphical launches and no-window hosts +do not mount this presentation. + Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/ desired/enchantment state projection; Core.Net owns exact manifest and live message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast diff --git a/docs/research/2026-08-14-la8-character-management-ui.md b/docs/research/2026-08-14-la8-character-management-ui.md new file mode 100644 index 00000000..ac64abb8 --- /dev/null +++ b/docs/research/2026-08-14-la8-character-management-ui.md @@ -0,0 +1,104 @@ +# LA8 retained character-management UI evidence + +Date: 2026-08-14 + +This note records the retail and installed-DAT evidence for Campaign LA slice +LA8, plus the exact ownership and presentation boundary implemented by the +slice. LA7b remains the authority for pre-world Runtime and wire behavior. + +## Named-retail evidence + +The implementation was derived from +`docs/research/named-retail/acclient_2013_pseudo_c.txt` and the corresponding +`acclient.h` definition before the screen was written. + +- `DBObj::GetDIDByEnum` (`0x004153A0`) forwards to + `DBCache::GetDIDFromEnumStatic`; retail resolves the category/table mapping + before loading a LayoutDesc. +- `gmCharacterManagementUI::gmCharacterManagementUI` (`0x004EC8F0`) calls + `UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039A)`, then + binds ListBox `0x1000039D`, Create `0x100003A0`, Enter `0x100003A2`, Delete + `0x1000039F`, and Restore `0x1000039E`. +- The verbatim header at `acclient.h:56545` declares exactly that ListBox, + those four button pointers, the selected row/guid, and four dialog contexts. + It declares no viewport, `gmCG3DView`, or preview owner. +- `RebuildCharacterList` (`0x004EC3A0`) creates each row through + `AddItemFromTemplateList`, retains character identity, displays pending + deletion in red, sorts by ordinal name, moves greyed entries to the tail, + and restores/falls back selection. LA8 preserves the already canonical LA7b + display order and identity instead of sorting an App copy. +- `SelectCharacter` (`0x004EC160`) and `UpdateButtons` (`0x004EC240`) establish + the highlight and button matrix: no or greyed selection disables Enter and + Delete; an active selection shows/enables Delete; a greyed selection hides + Delete and shows/enables Restore. +- `ListenToElementMessage` (`0x004ED5A0`) routes the list selection message, + button clicks, and row-template `0x100003A5` activation message `0x1A`. + Double-activating a row calls `EnterGame` (`0x004ED440`). +- `MakeDeleteCharacterConfirmationDialog` (`0x004ECCA0`) uses retail dialog + type 5 and compares the typed response with the localized DELETE response + case-insensitively. `MakePleaseWaitDialog` (`0x004ECED0`) and + `MakeEnteringWorldDialog` (`0x004ED090`) use the wait machinery. Error + presentation enters through `MakeErrorMessageDialog` (`0x004ECB10`). The + destructor (`0x004EC080`) closes every owned dialog context. + +The shared dialog factory switch supplies catalog roots/classes used here: +message type 3 is root `0x24` / class `0x17`; confirmation-text-input type 5 +is root `0x2C` / class `0x15`; the existing wait type 2 is root `0x31` / +class `0x19`. The message button is `0x26`. Type 5 uses field `0x2C`, accept +`0x2E`, reject `0x2F`, and result property `0x9C`. + +## Installed-DAT proof + +The permanent read-only acceptance probe is +`tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs`. Run it +with `ACDREAM_PROBE_LIVE_MOUNT=1`; it reads the ordinary +`%USERPROFILE%/Documents/Asheron's Call` DAT set unless `ACDREAM_DAT_DIR` +overrides the location. It uses production `DatCollection`, +`RetailDataIdResolver`, and `LayoutImporter`; it does not write the DATs. + +The installed September-2013 data proves: + +- enum category/table 5 maps `0x10000005` to concrete LayoutDesc DID + **`0x21000004`**; +- selected root `0x1000039A` is 800 x 600 with eight authored children; +- the root itself authors image media `0x06007576`; that proves a retained + layout asset, not a separate render-loop background scene; +- its ListBox template is `{ 0x21000004, 0x100003A5 }`; +- the template is a 160 x 16 `UiButton`, font `0x40000009`, with Normal, + NormalRollover, NormalPressed, Highlight, HighlightRollover, and the authored + `0xFFFFFFFF` default state; +- the authored captions are Create Character, ENTER, DELETE, and RESTORE; +- neither the selected root nor any descendant is a `UiViewport`; +- enum-table-5 dialog key 2 maps to catalog DID `0x2100003C`, containing the + type-3 and type-5 roots/children above; +- string table `0x23000002` contains DELETE, Please Wait, Entering World, and + the delete-confirmation template. The template has the PLAYER variable and + resolves it into the selected character name. + +## Ownership, composition, and lifecycle + +`RetailUiRuntime` imports the exact enum-resolved root only for a graphical +launch with no explicit character selector. Its focused binding borrows +`IRuntimeCharacterSelectionView`; every highlight, enter, delete-request, +delete-confirm, restore, and cancel action crosses the existing deferred +adapter as a generation-capturing Runtime command. App retains no gameplay +mirror. Explicit-selector graphical launches keep their existing flow, and +headless does not compose this App presentation. + +The controller instantiates the authored row template in Runtime display +order, projects red pending-delete rows and the exact button matrix, and opens +the shared retail dialogs. Delete wait survives the opcode-only acknowledgement +until the fresh roster arrives. Restore is fire-and-observe: a silent ACE +no-reply ends only when Runtime expires its correlation. Entering-world wait +opens before the existing synchronous Enter command; error, reset, reconnect, +missing/displaced adapter, and disposal close owned contexts without re-entrant +commands. A failed transient row-template import leaves the Runtime revision +unconsumed and retries on the next frame. + +There is deliberately no 3D preview and no claimed character-select background +scene. The screen root remains neutral with respect to render-loop background +composition. LA11's user visual gate owns that unresolved visual choice, plus +the live local-ACE delete/restore check. Because Enter currently completes its +established ServerReady transaction synchronously, LA11 must also verify that +the entering-world wait is perceptible on the real frame path; this slice does +not introduce a second queue or lifecycle owner merely to force a paint. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 194eeb91..4b16c5fd 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -21,6 +21,7 @@ using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; @@ -937,7 +938,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory (action, held) => d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true, late.Automation), - Keyboard: new KeyboardRuntimeBindings(d.InputDispatcher, d.KeyBindingsFilePath)); + Keyboard: new KeyboardRuntimeBindings( + d.InputDispatcher, + d.KeyBindingsFilePath), + CharacterSelection: d.Options.LiveCharacterSelector is null + ? new CharacterSelectionRuntimeBindings( + () => late.GameRuntime.CharacterSelection, + late.GameRuntime.CharacterSelectionHighlight, + late.GameRuntime.CharacterSelectionEnter, + late.GameRuntime.CharacterSelectionRequestDelete, + late.GameRuntime.CharacterSelectionConfirmDelete, + late.GameRuntime.CharacterSelectionRestore, + late.GameRuntime.CharacterSelectionCancel) + : null); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 18ea229f..d7ddb795 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -10,6 +10,7 @@ using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; using Silk.NET.Windowing; @@ -37,6 +38,24 @@ internal sealed class DeferredGameRuntimeStateCommands } } + /// + /// Borrows the current adapter's character-selection projection. The + /// reference is deliberately not cached here: releasing or displacing the + /// exact late binding makes the next read return , + /// while CurrentGameRuntimeAdapter keeps an already-borrowed reference + /// inert if disposal races the render-thread consumer. + /// + public IRuntimeCharacterSelectionView? CharacterSelection + { + get + { + lock (_gate) + return !_deactivated && _view is not null + ? _view.CharacterSelection + : null; + } + } + public IDisposable Bind( IGameRuntimeView view, IGameRuntimeCommands commands) @@ -118,6 +137,35 @@ internal sealed class DeferredGameRuntimeStateCommands generation, new RuntimeAdvancementCommand(kind, statId, cost))); + // Campaign LA slice LA8: the retained character-management screen uses + // the same generation-capturing late seam as every gameplay panel. The + // screen never receives GameRuntime or WorldSession and cannot retain a + // stale generation across reconnect. + + public RuntimeCommandResult CharacterSelectionHighlight(uint characterId) => + Invoke((commands, generation) => + commands.CharacterSelection.Highlight(generation, characterId)); + + public RuntimeCommandResult CharacterSelectionEnter() => + Invoke((commands, generation) => + commands.CharacterSelection.Enter(generation)); + + public RuntimeCommandResult CharacterSelectionRequestDelete() => + Invoke((commands, generation) => + commands.CharacterSelection.RequestDelete(generation)); + + public RuntimeCommandResult CharacterSelectionConfirmDelete() => + Invoke((commands, generation) => + commands.CharacterSelection.ConfirmDelete(generation)); + + public RuntimeCommandResult CharacterSelectionRestore() => + Invoke((commands, generation) => + commands.CharacterSelection.Restore(generation)); + + public RuntimeCommandResult CharacterSelectionCancel() => + Invoke((commands, generation) => + commands.CharacterSelection.Cancel(generation)); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs new file mode 100644 index 00000000..5a2759a0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -0,0 +1,578 @@ +using System.Numerics; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// Projects Runtime's one borrowed pre-world character-selection owner through +/// retail gmCharacterManagementUI's authored retained layout. The list is +/// intentionally flat: the retail class owns no viewport or model preview. +/// +internal sealed class CharacterManagementUiController : IDisposable +{ + internal const uint RootEnum = 0x10000005u; + internal const uint RootElementId = 0x1000039Au; + internal const uint ListElementId = 0x1000039Du; + internal const uint CreateElementId = 0x100003A0u; + internal const uint EnterElementId = 0x100003A2u; + internal const uint DeleteElementId = 0x1000039Fu; + internal const uint RestoreElementId = 0x1000039Eu; + + internal sealed record DialogStrings( + Func DeleteConfirmation, + string DeleteResponse, + string PleaseWait, + string EnteringWorld); + + private readonly UiRoot _host; + private readonly ImportedLayout _layout; + private readonly UiTemplateListBox _list; + private readonly UiButton _create; + private readonly UiButton _enter; + private readonly UiButton _delete; + private readonly UiButton _restore; + private readonly RetailDialogFactory _dialogs; + private readonly CharacterSelectionRuntimeBindings _bindings; + private readonly DialogStrings _strings; + private readonly List _rows = []; + private readonly Dictionary _rowIds = []; + + private RuntimeGenerationToken _lastGeneration; + private long _lastRevision = long.MinValue; + private uint _deleteDialogContext; + private uint _operationWaitContext; + private uint _enterWaitContext; + private uint _errorDialogContext; + private bool _active; + private bool _suppressDialogCallbacks; + private bool _disposed; + + private CharacterManagementUiController( + UiRoot host, + ImportedLayout layout, + UiTemplateListBox list, + UiButton create, + UiButton enter, + UiButton delete, + UiButton restore, + RetailDialogFactory dialogs, + CharacterSelectionRuntimeBindings bindings, + DialogStrings strings) + { + _host = host; + _layout = layout; + _list = list; + _create = create; + _enter = enter; + _delete = delete; + _restore = restore; + _dialogs = dialogs; + _bindings = bindings; + _strings = strings; + + Root.Left = 0f; + Root.Top = 0f; + Root.Anchors = AnchorEdges.Left | AnchorEdges.Top + | AnchorEdges.Right | AnchorEdges.Bottom; + if (host.Width > 0f) + Root.Width = host.Width; + if (host.Height > 0f) + Root.Height = host.Height; + Root.ClickThrough = false; + Root.Visible = false; + + // Create Character belongs to a future campaign. Keep retail's + // authored control in place and visibly ghosted; do not hide it or + // invent an action. + _create.Visible = true; + _create.Enabled = false; + _create.OnClick = null; + _enter.OnClick = EnterSelected; + _delete.OnClick = RequestDelete; + _restore.OnClick = RestoreSelected; + } + + internal UiElement Root => _layout.Root; + internal IReadOnlyList Rows => _rows; + internal uint DeleteDialogContext => _deleteDialogContext; + internal uint OperationWaitContext => _operationWaitContext; + internal uint EnterWaitContext => _enterWaitContext; + internal uint ErrorDialogContext => _errorDialogContext; + + internal void ResetSession() + { + if (_disposed) + return; + Deactivate(); + _lastRevision = long.MinValue; + } + + internal static CharacterManagementUiController? Bind( + UiRoot host, + ImportedLayout layout, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterSelectionRuntimeBindings bindings, + DialogStrings strings) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(dialogs); + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(strings); + + if (ContainsViewport(layout.Root)) + { + Console.WriteLine( + "[UI] character management: refusing an unapproved model-preview viewport."); + return null; + } + + if (layout.Root.DatElementId != RootElementId + || layout.FindElement(ListElementId) is not UiTemplateListBox list + || layout.FindElement(CreateElementId) is not UiButton create + || layout.FindElement(EnterElementId) is not UiButton enter + || layout.FindElement(DeleteElementId) is not UiButton delete + || layout.FindElement(RestoreElementId) is not UiButton restore) + { + Console.WriteLine( + "[UI] character management: the authored root/list/button contract is incomplete."); + return null; + } + + list.TemplateResolver = templateResolver; + var controller = new CharacterManagementUiController( + host, + layout, + list, + create, + enter, + delete, + restore, + dialogs, + bindings, + strings); + host.AddChild(controller.Root); + controller.Tick(); + return controller; + } + + private static bool ContainsViewport(UiElement element) + { + if (element is UiViewport) + return true; + foreach (UiElement child in element.Children) + if (ContainsViewport(child)) + return true; + return false; + } + + internal void Tick() + { + if (_disposed) + return; + + IRuntimeCharacterSelectionView? view = _bindings.View(); + RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default; + if (view is null || !snapshot.IsActive) + { + Deactivate(); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + return; + } + + if (!_active) + { + _active = true; + Root.Visible = true; + _host.BringToFront(Root); + } + + if (_lastGeneration != snapshot.Generation + || _lastRevision != snapshot.Revision) + { + if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster)) + { + bool rowsReady; + if (RowsMatchRoster(roster)) + { + ApplyHighlight(snapshot.HighlightedCharacterId); + rowsReady = true; + } + else + { + rowsReady = RebuildRows( + roster, + snapshot.HighlightedCharacterId); + } + + if (rowsReady) + { + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + } + } + else + { + // A receive-thread roster/reset raced the borrowed snapshot. + // Leave the revision unconsumed so the next frame retries from + // one coherent view; never present a partially mixed roster. + _lastRevision = long.MinValue; + snapshot = view.Snapshot; + if (!snapshot.IsActive) + { + Deactivate(); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + return; + } + } + } + else + { + ApplyHighlight(snapshot.HighlightedCharacterId); + } + + ApplyButtons(snapshot.Buttons); + ReconcileDialogs(view, snapshot); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + CloseAllDialogs(suppressCallbacks: true); + _enter.OnClick = null; + _delete.OnClick = null; + _restore.OnClick = null; + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + _list.TemplateResolver = null; + _host.RemoveChild(Root); + } + + private static bool TryCaptureRoster( + IRuntimeCharacterSelectionView view, + RuntimeCharacterSelectionSnapshot expected, + out RuntimeCharacterSelectionEntry[] roster) + { + roster = new RuntimeCharacterSelectionEntry[expected.RosterCount]; + for (int i = 0; i < roster.Length; i++) + { + if (!view.TryGetAt(i, out roster[i])) + return false; + } + + RuntimeCharacterSelectionSnapshot after = view.Snapshot; + return after.Generation == expected.Generation + && after.Revision == expected.Revision + && after.RosterCount == expected.RosterCount; + } + + private bool RowsMatchRoster( + IReadOnlyList roster) + { + if (_rows.Count != roster.Count) + return false; + + for (int i = 0; i < roster.Count; i++) + { + UiButton row = _rows[i]; + RuntimeCharacterSelectionEntry character = roster[i]; + if (!_rowIds.TryGetValue(row, out uint characterId) + || characterId != character.CharacterId + || !string.Equals(row.Label, character.Name, StringComparison.Ordinal) + || row.LabelColor != (character.IsPendingDelete + ? new Vector4(1f, 0f, 0f, 1f) + : Vector4.One)) + { + return false; + } + } + + return true; + } + + private bool RebuildRows( + IReadOnlyList roster, + uint highlightedCharacterId) + { + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + + bool complete = true; + foreach (RuntimeCharacterSelectionEntry character in roster) + { + if (_list.AddItemFromTemplateList(0) is not UiButton row) + { + complete = false; + break; + } + + uint characterId = character.CharacterId; + row.Label = character.Name; + row.LabelColor = character.IsPendingDelete + ? new Vector4(1f, 0f, 0f, 1f) + : Vector4.One; + row.Enabled = true; + row.SuppressSelfToggle = true; + row.Selected = characterId == highlightedCharacterId; + row.OnClick = () => Highlight(characterId); + row.OnDoubleClick = EnterSelected; + _rows.Add(row); + _rowIds.Add(row, characterId); + } + + if (complete) + return true; + + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + _lastRevision = long.MinValue; + return false; + } + + private void ApplyHighlight(uint highlightedCharacterId) + { + foreach (UiButton row in _rows) + row.Selected = _rowIds.TryGetValue(row, out uint characterId) + && characterId == highlightedCharacterId; + } + + private void ApplyButtons(RuntimeCharacterSelectionButtons buttons) + { + _create.Visible = true; + _create.Enabled = false; + _enter.Enabled = buttons.CanEnter; + _delete.Visible = buttons.DeleteVisible; + _delete.Enabled = buttons.CanDelete; + _restore.Visible = buttons.RestoreVisible; + _restore.Enabled = buttons.CanRestore; + } + + private void Highlight(uint characterId) + { + if (_disposed) + return; + _bindings.Highlight(characterId); + InvalidateAndTick(); + } + + private void EnterSelected() + { + if (_disposed) + return; + + // Open retail's wait context before the synchronous Runtime command + // starts its existing ServerReady transaction. The state projection + // remains authoritative and closes it on InWorld/error/reset. + EnsureEnterWait(); + RuntimeCommandResult result = _bindings.Enter(); + if (!result.Accepted) + CloseContext(ref _enterWaitContext, suppressCallback: true); + InvalidateAndTick(); + } + + private void RequestDelete() + { + if (_disposed) + return; + _bindings.RequestDelete(); + InvalidateAndTick(); + } + + private void RestoreSelected() + { + if (_disposed) + return; + RuntimeCommandResult result = _bindings.Restore(); + if (result.Accepted) + EnsureOperationWait(); + InvalidateAndTick(); + } + + private void ReconcileDialogs( + IRuntimeCharacterSelectionView view, + RuntimeCharacterSelectionSnapshot snapshot) + { + if (snapshot.Error is { } error) + { + CloseContext(ref _deleteDialogContext, suppressCallback: true); + CloseContext(ref _operationWaitContext, suppressCallback: true); + CloseContext(ref _enterWaitContext, suppressCallback: true); + EnsureError(error.Message); + return; + } + + CloseContext(ref _errorDialogContext, suppressCallback: true); + if (snapshot.Lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld) + { + CloseContext(ref _deleteDialogContext, suppressCallback: true); + CloseContext(ref _operationWaitContext, suppressCallback: true); + EnsureEnterWait(); + return; + } + + CloseContext(ref _enterWaitContext, suppressCallback: true); + if (snapshot.PendingDeleteCharacterId != 0u + && view.TryGet(snapshot.PendingDeleteCharacterId, out RuntimeCharacterSelectionEntry pending)) + { + EnsureDeleteConfirmation(pending.Name); + } + else + { + CloseContext(ref _deleteDialogContext, suppressCallback: true); + } + + if (snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested + or RuntimeCharacterSelectionOperation.DeleteAcknowledged + or RuntimeCharacterSelectionOperation.RestoreRequested) + { + EnsureOperationWait(); + } + else + { + CloseContext(ref _operationWaitContext, suppressCallback: true); + } + } + + private void EnsureDeleteConfirmation(string characterName) + { + if (_deleteDialogContext != 0u) + return; + + _deleteDialogContext = _dialogs.MakeConfirmationTextInput( + _strings.DeleteConfirmation(characterName), + data => + { + _deleteDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + string response = data.GetString( + RetailDialogProperty.TextInputResult) ?? string.Empty; + if (string.Equals( + response, + _strings.DeleteResponse, + StringComparison.OrdinalIgnoreCase)) + { + _bindings.ConfirmDelete(); + } + else + { + _bindings.Cancel(); + } + InvalidateAndTick(); + }); + } + + private void EnsureOperationWait() + { + if (_operationWaitContext == 0u) + _operationWaitContext = _dialogs.MakeWait(_strings.PleaseWait); + } + + private void EnsureEnterWait() + { + if (_enterWaitContext == 0u) + _enterWaitContext = _dialogs.MakeWait(_strings.EnteringWorld); + } + + private void EnsureError(string message) + { + if (_errorDialogContext != 0u) + return; + _errorDialogContext = _dialogs.MakeMessage( + message, + _ => + { + _errorDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + _bindings.Cancel(); + InvalidateAndTick(); + }); + } + + private void InvalidateAndTick() + { + _lastRevision = long.MinValue; + Tick(); + } + + private void Deactivate() + { + if (_active) + { + _active = false; + Root.Visible = false; + } + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + CloseAllDialogs(suppressCallbacks: true); + } + + private void CloseAllDialogs(bool suppressCallbacks) + { + bool previous = _suppressDialogCallbacks; + _suppressDialogCallbacks |= suppressCallbacks; + try + { + CloseContext(ref _deleteDialogContext, suppressCallback: false); + CloseContext(ref _operationWaitContext, suppressCallback: false); + CloseContext(ref _enterWaitContext, suppressCallback: false); + CloseContext(ref _errorDialogContext, suppressCallback: false); + } + finally + { + _suppressDialogCallbacks = previous; + } + } + + private void CloseContext(ref uint context, bool suppressCallback) + { + uint closing = context; + if (closing == 0u) + return; + context = 0u; + + bool previous = _suppressDialogCallbacks; + _suppressDialogCallbacks |= suppressCallback; + try + { + _dialogs.CloseDialog(closing); + } + finally + { + _suppressDialogCallbacks = previous; + } + } +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 01804e44..5991ba57 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -122,6 +122,8 @@ public static class DatWidgetFactory 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 0x13 => new UiDialogRoot(), // ConfirmationDialog + 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog + 0x17 => new UiDialogRoot(), // MessageDialog 0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 0x10000035u => BuildCheckbox( diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs new file mode 100644 index 00000000..e21d330b --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs @@ -0,0 +1,160 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retail type-5 ConfirmationTextInputDialog (class type +/// 0x15, catalog root 0x2C). Accept stores the field text under +/// property 0x9C; reject/Escape stores the empty string. Character +/// deletion is the first consumer and performs retail's case-insensitive +/// comparison with the localized DELETE response in its callback. +/// +internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView +{ + public const uint RootElementId = 0x2Cu; + public const uint InputElementId = 0x2Cu; + public const uint AcceptButtonId = 0x2Eu; + public const uint RejectButtonId = 0x2Fu; + public const uint PopupElementId = 0x3Du; + public const uint MessageElementId = 0x3Eu; + + private readonly UiRoot _host; + private readonly RetailDialogData _data; + private readonly uint _context; + private readonly Action _closeDialog; + private readonly UiElement _popup; + private readonly UiText _message; + private readonly UiField _input; + private readonly UiButton _accept; + private readonly UiButton _reject; + private readonly float _basePopupHeight; + private readonly float _baseMessageHeight; + private bool _focusPending = true; + + public RetailConfirmationTextInputDialogView( + UiRoot host, + ImportedLayout layout, + RetailDialogData data, + uint context, + Action closeDialog) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + ArgumentNullException.ThrowIfNull(layout); + _data = data ?? throw new ArgumentNullException(nameof(data)); + _context = context; + _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog)); + + Root = layout.Root as UiDialogRoot + ?? throw new ArgumentException( + "Confirmation-text-input layout root is not a UiDialogRoot.", + nameof(layout)); + _popup = layout.FindElement(PopupElementId) + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing popup element 0x3D.", + nameof(layout)); + _message = layout.FindElement(MessageElementId) as UiText + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing text element 0x3E.", + nameof(layout)); + // The field deliberately repeats the root's numeric id. ImportedLayout + // registers descendants after ancestors, matching GetChildRecursive's + // effective result for this catalog shape. + _input = layout.FindElement(InputElementId) as UiField + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing input field 0x2C.", + nameof(layout)); + _accept = layout.FindElement(AcceptButtonId) as UiButton + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing accept button 0x2E.", + nameof(layout)); + _reject = layout.FindElement(RejectButtonId) as UiButton + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing reject button 0x2F.", + nameof(layout)); + + _basePopupHeight = _popup.Height; + _baseMessageHeight = _message.Height; + _popup.LayoutPolicy = null; + _popup.Anchors = AnchorEdges.None; + _message.LayoutPolicy = null; + _message.Anchors = AnchorEdges.None; + _message.Padding = 0f; + _message.Selectable = false; + _input.ClearOnSubmit = false; + _input.RecordHistory = false; + + if (_data.GetString(RetailDialogProperty.TextInputAcceptLabel) is { } acceptLabel) + _accept.Label = acceptLabel; + if (_data.GetString(RetailDialogProperty.TextInputRejectLabel) is { } rejectLabel) + _reject.Label = rejectLabel; + + Root.Cancel = Reject; + _accept.OnClick = Accept; + _reject.OnClick = Reject; + _input.OnSubmit = _ => Accept(); + SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty); + SizeAndCenter(); + } + + public UiDialogRoot Root { get; } + + public void Tick() + { + SizeAndCenter(); + if (_focusPending && Root.Parent is not null) + { + _host.SetKeyboardFocus(_input); + _focusPending = false; + } + } + + public void SetPendingCount(int count) + { + // This catalog root authors no pending-count display. + } + + public void DetachHandlers() + { + Root.Cancel = null; + _accept.OnClick = null; + _reject.OnClick = null; + _input.OnSubmit = null; + } + + private void Accept() + { + _data.Set(RetailDialogProperty.TextInputResult, _input.Text); + _closeDialog(_context); + } + + private void Reject() + { + _data.Set(RetailDialogProperty.TextInputResult, string.Empty); + _closeDialog(_context); + } + + private void SetMessage(string text) + { + float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding); + Func measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth); + var lines = new UiText.Line[wrapped.Count]; + for (int i = 0; i < wrapped.Count; i++) + lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor); + _message.LinesProvider = () => lines; + + float lineHeight = _message.DatFont?.LineHeight ?? 16f; + _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight); + _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight); + } + + private void SizeAndCenter() + { + Root.Left = 0f; + Root.Top = 0f; + Root.Width = _host.Width; + Root.Height = _host.Height; + _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); + _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs index 72512826..d8b61558 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -11,6 +11,9 @@ public static class RetailDialogProperty public const uint AcceptLabel = 0x90u; public const uint RejectLabel = 0x91u; public const uint ConfirmationResult = 0x92u; + public const uint TextInputAcceptLabel = 0x9Au; + public const uint TextInputRejectLabel = 0x9Bu; + public const uint TextInputResult = 0x9Cu; /// /// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean /// attribute 0x40. The Keystone-owned attribute name is unavailable. @@ -123,4 +126,21 @@ public sealed class RetailDialogData .Set(RetailDialogProperty.ElementAttribute40, true) .Set(RetailDialogProperty.Message, message); } + + public static RetailDialogData Message(string message) + { + ArgumentNullException.ThrowIfNull(message); + return new RetailDialogData() + .Set(RetailDialogProperty.Type, RetailDialogType.Message) + .Set(RetailDialogProperty.Message, message); + } + + public static RetailDialogData ConfirmationTextInput(string message) + { + ArgumentNullException.ThrowIfNull(message); + return new RetailDialogData() + .Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationTextInput) + .Set(RetailDialogProperty.ElementAttribute40, true) + .Set(RetailDialogProperty.Message, message); + } } diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 554c525c..33a3bf3a 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -148,6 +148,26 @@ public sealed class RetailDialogFactory : IDisposable return MakeDialog(data, callback: null); } + public uint MakeMessage( + string message, + Action? callback = null, + uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.Message(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback); + } + + public uint MakeConfirmationTextInput( + string message, + Action? callback = null, + uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.ConfirmationTextInput(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback); + } + /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. @@ -275,7 +295,10 @@ public sealed class RetailDialogFactory : IDisposable private void CreateDialog(DialogInfo info) { RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type); - if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait)) + if (type is not (RetailDialogType.Confirmation + or RetailDialogType.Wait + or RetailDialogType.Message + or RetailDialogType.ConfirmationTextInput)) throw new NotSupportedException( $"Retail dialog type {(uint)type} does not have a ported presenter yet."); @@ -285,6 +308,13 @@ public sealed class RetailDialogFactory : IDisposable IRetailDialogView view = type switch { RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data), + RetailDialogType.Message => new RetailMessageDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), + RetailDialogType.ConfirmationTextInput => + new RetailConfirmationTextInputDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), _ => new RetailConfirmationDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), @@ -294,6 +324,7 @@ public sealed class RetailDialogFactory : IDisposable _host.BringToFront(view.Root); _openOrder.Add(info); _host.Modal = view.Root; + view.Tick(); UpdatePendingDialogDisplays(); DialogOpened?.Invoke(info.Context); } diff --git a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs new file mode 100644 index 00000000..a9246ac5 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs @@ -0,0 +1,105 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retail type-3 MessageDialog (class type 0x17, catalog root +/// 0x24). It shares the dialog catalog's popup/message pair with the +/// existing confirmation and wait presenters and closes from its authored OK +/// button 0x26 or Escape. +/// +internal sealed class RetailMessageDialogView : IRetailDialogView +{ + public const uint RootElementId = 0x24u; + public const uint OkButtonId = 0x26u; + public const uint PopupElementId = 0x3Du; + public const uint MessageElementId = 0x3Eu; + + private readonly UiRoot _host; + private readonly uint _context; + private readonly Action _closeDialog; + private readonly UiElement _popup; + private readonly UiText _message; + private readonly UiButton _ok; + private readonly float _basePopupHeight; + private readonly float _baseMessageHeight; + + public RetailMessageDialogView( + UiRoot host, + ImportedLayout layout, + RetailDialogData data, + uint context, + Action closeDialog) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(data); + _context = context; + _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog)); + + Root = layout.Root as UiDialogRoot + ?? throw new ArgumentException("Message layout root is not a UiDialogRoot.", nameof(layout)); + _popup = layout.FindElement(PopupElementId) + ?? throw new ArgumentException("Message layout is missing popup element 0x3D.", nameof(layout)); + _message = layout.FindElement(MessageElementId) as UiText + ?? throw new ArgumentException("Message layout is missing text element 0x3E.", nameof(layout)); + _ok = layout.FindElement(OkButtonId) as UiButton + ?? throw new ArgumentException("Message layout is missing OK button 0x26.", nameof(layout)); + + _basePopupHeight = _popup.Height; + _baseMessageHeight = _message.Height; + _popup.LayoutPolicy = null; + _popup.Anchors = AnchorEdges.None; + _message.LayoutPolicy = null; + _message.Anchors = AnchorEdges.None; + _message.Padding = 0f; + _message.Selectable = false; + + Root.Cancel = Close; + _ok.OnClick = Close; + SetMessage(data.GetString(RetailDialogProperty.Message) ?? string.Empty); + SizeAndCenter(); + } + + public UiDialogRoot Root { get; } + + public void Tick() => SizeAndCenter(); + + public void SetPendingCount(int count) + { + // MessageDialog has no pending-count subtree in the retail catalog. + } + + public void DetachHandlers() + { + Root.Cancel = null; + _ok.OnClick = null; + } + + private void Close() => _closeDialog(_context); + + private void SetMessage(string text) + { + float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding); + Func measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth); + var lines = new UiText.Line[wrapped.Count]; + for (int i = 0; i < wrapped.Count; i++) + lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor); + _message.LinesProvider = () => lines; + + float lineHeight = _message.DatFont?.LineHeight ?? 16f; + _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight); + _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight); + } + + private void SizeAndCenter() + { + Root.Left = 0f; + Root.Top = 0f; + Root.Width = _host.Width; + Root.Height = _host.Height; + _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); + _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index b4164367..df70aa93 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -16,6 +16,7 @@ using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; using AcDream.Content; using AcDream.Core.Input; using AcDream.UI.Abstractions; @@ -369,6 +370,21 @@ public sealed record KeyboardRuntimeBindings( InputDispatcher? Dispatcher, string KeyBindingsFilePath); +/// +/// Borrowed LA7b character-selection projection and its generation-capturing +/// typed command routes. App owns no roster, selection, operation, or error +/// mirror; an absent view means the current adapter has not bound (or has +/// already been released). +/// +public sealed record CharacterSelectionRuntimeBindings( + Func View, + Func Highlight, + Func Enter, + Func RequestDelete, + Func ConfirmDelete, + Func Restore, + Func Cancel); + public sealed record RetailUiRuntimeBindings( UiHost Host, RetailUiAssets Assets, @@ -395,7 +411,8 @@ public sealed record RetailUiRuntimeBindings( BufferedUiRegistry? Plugins, RetailUiPersistenceBindings? Persistence, RetailUiProbeBindings Probe, - KeyboardRuntimeBindings? Keyboard = null); + KeyboardRuntimeBindings? Keyboard = null, + CharacterSelectionRuntimeBindings? CharacterSelection = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -483,6 +500,7 @@ public sealed class RetailUiRuntime : IDisposable MountVendor(); MountSecureTrade(); MountItemCooldowns(); + MountCharacterManagement(); Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; BindToolbarPanelButtons(); SyncToolbarWindowButtons(); @@ -577,6 +595,7 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } + internal CharacterManagementUiController? CharacterManagementController { get; private set; } public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { @@ -622,6 +641,7 @@ public sealed class RetailUiRuntime : IDisposable ExternalContainerController?.Tick(); SocialPanelController?.Tick(); _itemCooldownController?.Tick(); + CharacterManagementController?.Tick(); DialogFactory?.Tick(); Host.Tick(deltaSeconds); _automation?.Tick(deltaSeconds); @@ -788,6 +808,7 @@ public sealed class RetailUiRuntime : IDisposable { try { + CharacterManagementController?.ResetSession(); DialogFactory?.Reset(); } finally @@ -3669,6 +3690,144 @@ public sealed class RetailUiRuntime : IDisposable "[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D."); } + private void MountCharacterManagement() + { + CharacterSelectionRuntimeBindings? bindings = + _bindings.CharacterSelection; + if (bindings is null) + return; + if (DialogFactory is null) + { + Console.WriteLine( + "[UI] character management: retail DialogFactory is unavailable."); + return; + } + + const uint stringTableId = 0x23000002u; + uint layoutId; + ImportedLayout? layout; + var strings = new DatStringResolver(_bindings.Assets.Dats); + lock (_bindings.Assets.DatLock) + { + // gmCharacterManagementUI's framework call passes enum + // 0x10000005 and category/table 5, then selects root 0x1000039A. + layoutId = RetailDataIdResolver.Resolve( + _bindings.Assets.Dats, + CharacterManagementUiController.RootEnum, + 5u); + layout = layoutId == 0u + ? null + : LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + CharacterManagementUiController.RootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + + if (layout is null) + { + Console.WriteLine( + "[UI] character management: enum-table-5 root could not be imported."); + return; + } + + string? deleteResponse; + string? deleteConfirmationProbe; + string? pleaseWait; + string? enteringWorld; + lock (_bindings.Assets.DatLock) + { + deleteConfirmationProbe = strings.ResolveTemplate( + stringTableId, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = string.Empty, + }); + deleteResponse = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharacterManagement_DeleteCharacterResponse"); + pleaseWait = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharacterManagement_PleaseWait"); + enteringWorld = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_Character_EnteringWorld"); + } + + if (deleteConfirmationProbe is null + || deleteResponse is null + || pleaseWait is null + || enteringWorld is null) + { + Console.WriteLine( + "[UI] character management: required retail strings are unavailable."); + return; + } + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + { + return LayoutImporter.Import( + _bindings.Assets.Dats, + templateLayoutId, + templateElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont)?.Root; + } + } + + string ComposeDeleteConfirmation(string characterName) + { + lock (_bindings.Assets.DatLock) + { + return NormalizeRetailNewlines(strings.ResolveTemplate( + stringTableId, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = characterName, + })!); + } + } + + CharacterManagementController = CharacterManagementUiController.Bind( + Host.Root, + layout, + ResolveTemplate, + DialogFactory, + bindings, + new CharacterManagementUiController.DialogStrings( + ComposeDeleteConfirmation, + deleteResponse, + pleaseWait, + enteringWorld)); + + if (CharacterManagementController is null) + return; + Console.WriteLine( + $"[UI] retail character management from enum table 5 " + + $"(0x10000005 -> 0x{layoutId:X8}, root 0x1000039A; flat list, no viewport)."); + } + + private static string? ResolveCharacterManagementString( + DatStringResolver strings, + uint tableId, + string key) => + strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value + ? NormalizeRetailNewlines(value) + : null; + + private static string NormalizeRetailNewlines(string value) => + value.Replace("\\n", "\n", StringComparison.Ordinal); + private void MountItemCooldowns() { ItemCooldownAssets? assets; @@ -3712,7 +3871,11 @@ public sealed class RetailUiRuntime : IDisposable } }, () => _itemConfirmationController?.Dispose(), - () => _gameplayConfirmationController?.Dispose(), + () => + { + CharacterManagementController?.Dispose(); + _gameplayConfirmationController?.Dispose(); + }, () => DialogFactory?.Dispose(), _panelUi.Dispose, Host.Dispose); diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 046f2973..b748894b 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -49,6 +49,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). public Action? OnClick { get; set; } + /// + /// Optional left-button double-click handler. Null preserves the existing + /// bubbling behavior; character-management row template 0x100003A5 opts in + /// for retail's element message 0x1A (activate the selected character). + /// + public Action? OnDoubleClick { get; set; } + /// /// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard /// screen: right-click a bound key button to erase that one binding — @@ -551,6 +558,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful OnClick?.Invoke(); OnClickAt?.Invoke(e.Data1, e.Data2); return OnClick is not null || OnClickAt is not null; + case UiEventType.DoubleClick: + if (OnDoubleClick is null) return false; + if (!Enabled) return true; + OnDoubleClick.Invoke(); + return true; case UiEventType.RightClick: // S6 (2026-08-11 review): unlike Click (whose swallow-when- // disabled is pre-existing, harmless-by-construction behavior diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 6600398c..c9318931 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -11,6 +11,7 @@ using AcDream.App.World; using AcDream.Core.Net; using AcDream.Core.World; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; namespace AcDream.App.Tests.Composition; @@ -106,6 +107,44 @@ public sealed class InteractionUiRuntimeSourcesTests Assert.Throws(() => source.Bind(first, first)); } + [Fact] + public void CharacterSelectionProjectionBorrowsExactAdapterAndEveryRouteBecomesInert() + { + var source = new DeferredGameRuntimeStateCommands(); + var target = new RuntimeTarget(new RuntimeGenerationToken(11)); + + Assert.Null(source.CharacterSelection); + Assert.Equal(RuntimeCommandStatus.Inactive, + source.CharacterSelectionEnter().Status); + + IDisposable binding = source.Bind(target, target); + Assert.Same(target, source.CharacterSelection); + RuntimeCommandResult[] accepted = + [ + source.CharacterSelectionHighlight(0x50000001u), + source.CharacterSelectionEnter(), + source.CharacterSelectionRequestDelete(), + source.CharacterSelectionConfirmDelete(), + source.CharacterSelectionRestore(), + source.CharacterSelectionCancel(), + ]; + Assert.All(accepted, result => + { + Assert.True(result.Accepted); + Assert.Equal(new RuntimeGenerationToken(11), result.Generation); + }); + + binding.Dispose(); + Assert.Null(source.CharacterSelection); + Assert.Equal(RuntimeCommandStatus.Inactive, + source.CharacterSelectionRestore().Status); + + source.Deactivate(); + Assert.Null(source.CharacterSelection); + Assert.Equal(RuntimeCommandStatus.Inactive, + source.CharacterSelectionCancel().Status); + } + [Fact] public void RadarNeverCachesAnUnboundBootstrapSnapshot() { @@ -216,7 +255,9 @@ public sealed class InteractionUiRuntimeSourcesTests IGameRuntimeCommands, IRuntimeInventoryStateCommands, IRuntimeSpellbookCommands, - IRuntimeCharacterCommands + IRuntimeCharacterCommands, + IRuntimeCharacterSelectionView, + IRuntimeCharacterSelectionCommands { public RuntimeTarget(RuntimeGenerationToken generation) { @@ -234,6 +275,7 @@ public sealed class InteractionUiRuntimeSourcesTests public IRuntimeCharacterView Character => null!; public IRuntimeSocialView Social => null!; public IRuntimeChatView Chat => null!; + public IRuntimeCharacterSelectionView CharacterSelection => this; public IRuntimeFellowshipView Fellowship => null!; public IRuntimeAllegianceView Allegiance => null!; public IRuntimeActionView Actions => null!; @@ -242,6 +284,7 @@ public sealed class InteractionUiRuntimeSourcesTests null!; public IRuntimePortalView Portal => null!; public IRuntimeSessionCommands Session => null!; + IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => this; public IRuntimeSelectionCommands Selection => null!; public IRuntimeCombatCommands Combat => null!; public IRuntimeMagicCommands Magic => null!; @@ -257,6 +300,67 @@ public sealed class InteractionUiRuntimeSourcesTests public RuntimeStateCheckpoint CaptureCheckpoint() => default; + RuntimeCharacterSelectionSnapshot IRuntimeCharacterSelectionView.Snapshot => + new( + Generation, + RuntimeCharacterSelectionLifecycle.AwaitingSelection, + Revision: 1, + AccountName: "account", + SlotCount: 0, + RosterCount: 0, + HighlightedCharacterId: 0u, + HighlightedDisplayIndex: -1, + PendingDeleteCharacterId: 0u, + LastRestoreRequestedCharacterId: 0u, + Operation: RuntimeCharacterSelectionOperation.None, + Error: null, + Buttons: RuntimeCharacterSelectionButtons.None); + + public bool TryGetAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) + { + character = default; + return false; + } + + public bool TryGet( + uint characterId, + out RuntimeCharacterSelectionEntry character) + { + character = default; + return false; + } + + public void Visit(IRuntimeCharacterSelectionVisitor visitor) { } + + public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) => + EmptyDisposable.Instance; + + public RuntimeCommandResult Highlight( + RuntimeGenerationToken expectedGeneration, + uint characterId) => Accepted(expectedGeneration, characterId); + + public RuntimeCommandResult Enter( + RuntimeGenerationToken expectedGeneration) => + Accepted(expectedGeneration); + + public RuntimeCommandResult RequestDelete( + RuntimeGenerationToken expectedGeneration) => + Accepted(expectedGeneration); + + public RuntimeCommandResult ConfirmDelete( + RuntimeGenerationToken expectedGeneration) => + Accepted(expectedGeneration); + + public RuntimeCommandResult Restore( + RuntimeGenerationToken expectedGeneration) => + Accepted(expectedGeneration); + + public RuntimeCommandResult Cancel( + RuntimeGenerationToken expectedGeneration) => + Accepted(expectedGeneration); + public RuntimeCommandResult AddShortcut( RuntimeGenerationToken expectedGeneration, in RuntimeShortcutCommand command) => @@ -392,6 +496,12 @@ public sealed class InteractionUiRuntimeSourcesTests } } + private sealed class EmptyDisposable : IDisposable + { + public static EmptyDisposable Instance { get; } = new(); + public void Dispose() { } + } + private sealed class EmptyRadarSource : ILiveEntityRadarSource { public static EmptyRadarSource Instance { get; } = new(); diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs index 807c007e..319982fa 100644 --- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs @@ -137,12 +137,19 @@ public sealed class SessionPlayerCompositionTests [Fact] public void GraphicalCompositionPausesOnlyWhenCharacterSelectorIsAbsent() { + string root = FindRepoRoot(); string phase = File.ReadAllText(Path.Combine( - FindRepoRoot(), + root, "src", "AcDream.App", "Composition", "SessionPlayerComposition.cs")); + string retainedUi = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Composition", + "InteractionRetainedUiComposition.cs")); Assert.Contains( "AwaitCharacterSelection:", @@ -156,6 +163,18 @@ public sealed class SessionPlayerCompositionTests "CharacterList.TrySelectFirstAvailable", phase, StringComparison.Ordinal); + Assert.Contains( + "CharacterSelection: d.Options.LiveCharacterSelector is null", + retainedUi, + StringComparison.Ordinal); + Assert.Contains( + "() => late.GameRuntime.CharacterSelection", + retainedUi, + StringComparison.Ordinal); + Assert.Contains( + "late.GameRuntime.CharacterSelectionEnter", + retainedUi, + StringComparison.Ordinal); } private sealed class RetryBinding( diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs new file mode 100644 index 00000000..15c705ae --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -0,0 +1,164 @@ +using System.IO; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; +using StringTable = DatReaderWriter.DBObjs.StringTable; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Installed-retail-DAT acceptance gate for LA8. Opt in with +/// ACDREAM_PROBE_LIVE_MOUNT=1; ACDREAM_DAT_DIR can override the +/// ordinary Documents/Asheron's Call location. Reads the DATs read-only. +/// +public sealed class CharacterManagementLiveDatTests +{ + [Fact] + public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs() + { + if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1") + return; + + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDirectory, DatAccessType.Read); + + const uint expectedLayoutDid = 0x21000004u; + uint layoutDid = RetailDataIdResolver.Resolve( + dats, + CharacterManagementUiController.RootEnum, + 5u); + Assert.Equal(expectedLayoutDid, layoutDid); + Console.WriteLine( + "[LA8-DAT] category=5 enum=0x10000005 -> DID=0x21000004; " + + "selected-root=0x1000039A"); + + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, + layoutDid, + CharacterManagementUiController.RootElementId)); + Assert.Equal(800f, rootInfo.Width); + Assert.Equal(600f, rootInfo.Height); + Assert.Equal(8, rootInfo.Children.Count); + Assert.Equal(0x06007576u, rootInfo.StateMedia[""].File); + ImportedLayout screen = LayoutImporter.Build( + rootInfo, + _ => (0u, 0, 0), + null, + null, + new DatStringResolver(dats).Resolve); + + var list = Assert.IsType(screen.FindElement( + CharacterManagementUiController.ListElementId)); + UiTemplateListEntry template = Assert.Single(list.Templates); + Assert.Equal(expectedLayoutDid, template.TemplateLayoutId); + Assert.Equal(0x100003A5u, template.TemplateElementId); + AssertButton(screen, CharacterManagementUiController.CreateElementId, + "Create Character"); + AssertButton(screen, CharacterManagementUiController.EnterElementId, + "ENTER"); + AssertButton(screen, CharacterManagementUiController.DeleteElementId, + "DELETE"); + AssertButton(screen, CharacterManagementUiController.RestoreElementId, + "RESTORE"); + Assert.DoesNotContain( + Descendants(screen.Root), + static element => element is UiViewport); + + ElementInfo rowInfo = Assert.IsType( + LayoutImporter.ImportInfos(dats, layoutDid, template.TemplateElementId)); + Assert.Equal(1u, rowInfo.Type); + Assert.Equal(160f, rowInfo.Width); + Assert.Equal(16f, rowInfo.Height); + Assert.Equal(0x40000009u, rowInfo.FontDid); + Assert.Equal( + [ + UiButtonStateMachine.Normal, + UiButtonStateMachine.NormalRollover, + UiButtonStateMachine.NormalPressed, + UiButtonStateMachine.Highlight, + UiButtonStateMachine.HighlightRollover, + uint.MaxValue, + ], + rowInfo.States.Keys.Order().ToArray()); + + uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); + Assert.Equal(0x2100003Cu, dialogDid); + ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u); + Assert.IsType(message.Root); + Assert.IsType(message.FindElement(0x3Eu)); + Assert.IsType(message.FindElement(0x26u)); + ImportedLayout delete = BuildSelected(dats, dialogDid, 0x2Cu); + Assert.IsType(delete.Root); + Assert.IsType(delete.FindElement(0x2Cu)); + Assert.IsType(delete.FindElement(0x2Eu)); + Assert.IsType(delete.FindElement(0x2Fu)); + + var strings = new DatStringResolver(dats); + const uint table = 0x23000002u; + Assert.Equal("DELETE", Resolve(strings, table, + "ID_CharacterManagement_DeleteCharacterResponse")); + Assert.Equal("Please Wait", Resolve(strings, table, + "ID_CharacterManagement_PleaseWait")); + Assert.Equal("Entering World", Resolve(strings, table, + "ID_Character_EnteringWorld")); + string confirmation = Assert.IsType(strings.ResolveTemplate( + table, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = "Test Character", + })); + Assert.Contains("Test Character", confirmation); + Assert.Contains("'DELETE'", confirmation); + + StringTable stringTable = Assert.IsType(dats.Get(table)); + var deleteEntry = stringTable.Strings[ + DatStringResolver.ComputeHash( + "ID_CharacterManagement_DeleteCharacterConfirmation")]; + Assert.Equal([DatStringResolver.PlayerVariable], deleteEntry.Variables); + } + + private static ImportedLayout BuildSelected( + IDatReaderWriter dats, + uint layoutDid, + uint rootId) + { + ElementInfo info = Assert.IsType( + LayoutImporter.ImportInfos(dats, layoutDid, rootId)); + return LayoutImporter.Build( + info, + _ => (0u, 0, 0), + null, + null, + new DatStringResolver(dats).Resolve); + } + + private static string Resolve( + DatStringResolver strings, + uint table, + string key) => Assert.IsType(strings.Resolve( + table, + DatStringResolver.ComputeHash(key))); + + private static void AssertButton( + ImportedLayout layout, + uint elementId, + string label) => Assert.Equal( + label, + Assert.IsType(layout.FindElement(elementId)).Label); + + private static IEnumerable Descendants(UiElement root) + { + yield return root; + foreach (UiElement child in root.Children) + foreach (UiElement descendant in Descendants(child)) + yield return descendant; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs new file mode 100644 index 00000000..93920e74 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -0,0 +1,716 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class CharacterManagementUiControllerTests +{ + [Fact] + public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + + Assert.True(controller.Root.Visible); + Assert.Equal( + CharacterManagementUiController.RootElementId, + controller.Root.DatElementId); + Assert.IsType(environment.Screen.FindElement( + CharacterManagementUiController.ListElementId)); + UiButton create = environment.Button( + CharacterManagementUiController.CreateElementId); + UiButton enter = environment.Button( + CharacterManagementUiController.EnterElementId); + UiButton delete = environment.Button( + CharacterManagementUiController.DeleteElementId); + UiButton restore = environment.Button( + CharacterManagementUiController.RestoreElementId); + + Assert.True(create.Visible); + Assert.False(create.Enabled); + Assert.Null(create.OnClick); + Assert.True(enter.Enabled); + Assert.True(delete.Visible); + Assert.True(delete.Enabled); + Assert.False(restore.Visible); + Assert.False(restore.Enabled); + + // Runtime owns wcscmp sorting and the stable grey-to-tail partition. + // "Aaron (pending)" would sort first if App incorrectly re-sorted it; + // the controller must preserve this exact borrowed display order. + Assert.Equal( + ["Alpha", "Zulu", "Aaron (pending)"], + controller.Rows.Select(static row => row.Label!).ToArray()); + Assert.True(controller.Rows[0].Selected); + Assert.False(controller.Rows[1].Selected); + Assert.Equal(Vector4.One, controller.Rows[0].LabelColor); + Assert.Equal(new Vector4(1f, 0f, 0f, 1f), controller.Rows[2].LabelColor); + Assert.DoesNotContain( + Descendants(controller.Root), + static element => element is UiViewport); + + controller.Rows[2].OnClick!(); + + Assert.Equal(1, environment.Runtime.HighlightCalls); + Assert.Equal(0x50000003u, + environment.Runtime.View.Snapshot.HighlightedCharacterId); + Assert.True(controller.Rows[2].Selected); + Assert.False(enter.Enabled); + Assert.False(delete.Visible); + Assert.False(delete.Enabled); + Assert.True(restore.Visible); + Assert.True(restore.Enabled); + } + + [Fact] + public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton delete = environment.Button( + CharacterManagementUiController.DeleteElementId); + + // A wrong typed response closes the modal and cancels Runtime's exact + // pending-delete owner without sending the wire request. + delete.OnClick!(); + ImportedLayout wrong = environment.LastDialog( + RetailDialogType.ConfirmationTextInput); + Assert.Contains("Alpha", Message(wrong)); + Assert.Contains("Type DELETE", Message(wrong)); + Input(wrong).SetText("not delete"); + DialogButton( + wrong, + RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!(); + Assert.Equal(1, environment.Runtime.CancelCalls); + Assert.Equal(0, environment.Runtime.ConfirmDeleteCalls); + Assert.Equal(0u, controller.DeleteDialogContext); + Assert.False(environment.Dialogs.IsOpen); + + // Retail compares the localized response case-insensitively. + delete.OnClick!(); + ImportedLayout accepted = environment.LastDialog( + RetailDialogType.ConfirmationTextInput); + Input(accepted).SetText("delete"); + DialogButton( + accepted, + RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!(); + + Assert.Equal(1, environment.Runtime.ConfirmDeleteCalls); + Assert.Equal( + RuntimeCharacterSelectionOperation.DeleteRequested, + environment.Runtime.View.Snapshot.Operation); + uint waitContext = controller.OperationWaitContext; + Assert.NotEqual(0u, waitContext); + Assert.Equal( + RetailDialogType.Wait, + environment.DialogLayouts[^1].Type); + Assert.Equal("Please Wait", Message(environment.DialogLayouts[^1].Layout)); + + // Opcode-only ack does not close the wait. Neither does silence. + environment.Runtime.SetOperation( + RuntimeCharacterSelectionOperation.DeleteAcknowledged); + controller.Tick(); + controller.Tick(); + Assert.Equal(waitContext, controller.OperationWaitContext); + + // Retail closes via the fresh CharacterList rebuild that follows ack. + environment.Runtime.ReplaceRoster( + [ + new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u), + new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u), + ], + highlightedCharacterId: 0x50000002u); + controller.Tick(); + + Assert.Equal(0u, controller.OperationWaitContext); + Assert.False(environment.Dialogs.IsOpen); + Assert.Equal( + ["Zulu", "Aaron (pending)"], + controller.Rows.Select(static row => row.Label!).ToArray()); + } + + [Fact] + public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton row = controller.Rows[1]; + + Assert.True(row.OnEvent(new UiEvent( + row.EventId, + row, + UiEventType.Click))); + Assert.True(row.OnEvent(new UiEvent( + row.EventId, + row, + UiEventType.DoubleClick))); + + Assert.Equal(1, environment.Runtime.HighlightCalls); + Assert.Equal(0x50000002u, + environment.Runtime.View.Snapshot.HighlightedCharacterId); + Assert.Equal(1, environment.Runtime.EnterCalls); + Assert.Equal( + RuntimeCharacterSelectionLifecycle.EnteringWorld, + environment.Runtime.View.Snapshot.Lifecycle); + Assert.NotEqual(0u, controller.EnterWaitContext); + } + + [Fact] + public void RestoreSilenceExpires_EnterTransitions_AndErrorUsesMessageDialog() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + + controller.Rows[2].OnClick!(); + environment.Button(CharacterManagementUiController.RestoreElementId).OnClick!(); + uint restoreWait = controller.OperationWaitContext; + Assert.NotEqual(0u, restoreWait); + Assert.Equal( + RuntimeCharacterSelectionOperation.RestoreRequested, + environment.Runtime.View.Snapshot.Operation); + + // ACE may send no restore response. Runtime's correlation expiry is + // represented by Operation=None; the presentation never owns a timer. + controller.Tick(); + Assert.Equal(restoreWait, controller.OperationWaitContext); + environment.Runtime.SetOperation(RuntimeCharacterSelectionOperation.None); + controller.Tick(); + Assert.Equal(0u, controller.OperationWaitContext); + Assert.False(environment.Dialogs.IsOpen); + + controller.Rows[0].OnClick!(); + environment.Button(CharacterManagementUiController.EnterElementId).OnClick!(); + Assert.Equal(1, environment.Runtime.EnterCalls); + Assert.Equal( + RuntimeCharacterSelectionLifecycle.EnteringWorld, + environment.Runtime.View.Snapshot.Lifecycle); + Assert.NotEqual(0u, controller.EnterWaitContext); + Assert.Equal("Entering World", Message( + environment.LastDialog(RetailDialogType.Wait))); + + environment.Runtime.SetError("That character is unavailable."); + controller.Tick(); + Assert.Equal(0u, controller.EnterWaitContext); + Assert.NotEqual(0u, controller.ErrorDialogContext); + ImportedLayout error = environment.LastDialog(RetailDialogType.Message); + Assert.Equal("That character is unavailable.", Message(error)); + DialogButton(error, RetailMessageDialogView.OkButtonId).OnClick!(); + Assert.Equal(1, environment.Runtime.CancelCalls); + Assert.Null(environment.Runtime.View.Snapshot.Error); + Assert.False(environment.Dialogs.IsOpen); + + // CharacterError.NumErrors is ignored by Runtime without a revision; + // an unchanged, error-free projection must not manufacture a dialog. + int createdBeforeSentinel = environment.DialogLayouts.Count; + controller.Tick(); + Assert.Equal(createdBeforeSentinel, environment.DialogLayouts.Count); + + environment.Button(CharacterManagementUiController.EnterElementId).OnClick!(); + Assert.NotEqual(0u, controller.EnterWaitContext); + environment.Runtime.SetLifecycle( + RuntimeCharacterSelectionLifecycle.InWorld); + controller.Tick(); + Assert.False(controller.Root.Visible); + Assert.Equal(0u, controller.EnterWaitContext); + Assert.False(environment.Dialogs.IsOpen); + } + + [Fact] + public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!(); + Assert.NotEqual(0u, controller.DeleteDialogContext); + + environment.Runtime.ProvideView = false; + controller.Tick(); + + Assert.False(controller.Root.Visible); + Assert.Empty(controller.Rows); + Assert.False(environment.Dialogs.IsOpen); + Assert.Equal(0, environment.Runtime.CancelCalls); + + controller.Dispose(); + Assert.Null(controller.Root.Parent); + Assert.Null(environment.Button( + CharacterManagementUiController.EnterElementId).OnClick); + controller.Tick(); + } + + [Fact] + public void SessionReset_ClosesOwnedContextsWithoutReentrantCancel() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + environment.Button(CharacterManagementUiController.DeleteElementId).OnClick!(); + Assert.NotEqual(0u, controller.DeleteDialogContext); + + controller.ResetSession(); + + Assert.False(controller.Root.Visible); + Assert.Empty(controller.Rows); + Assert.False(environment.Dialogs.IsOpen); + Assert.Equal(0, environment.Runtime.CancelCalls); + } + + [Fact] + public void PreviewViewport_IsRejectedBeforeTheAuthoredScreenIsMounted() + { + var host = new UiRoot { Width = 800f, Height = 600f }; + ImportedLayout screen = BuildScreen(includePreview: true); + using var dialogs = new RetailDialogFactory( + host, + RetailDialogFactoryTests.BuildDialogLayout); + var runtime = new FakeRuntime(); + + CharacterManagementUiController? controller = + CharacterManagementUiController.Bind( + host, + screen, + static (_, _) => BuildRow(), + dialogs, + runtime.Bindings, + TestStrings()); + + Assert.Null(controller); + Assert.Empty(host.Children); + } + + [Fact] + public void TransientTemplateMiss_DoesNotConsumeTheRuntimeRevision() + { + var host = new UiRoot { Width = 800f, Height = 600f }; + ImportedLayout screen = BuildScreen(); + using var dialogs = new RetailDialogFactory( + host, + RetailDialogFactoryTests.BuildDialogLayout); + var runtime = new FakeRuntime(); + int resolveCalls = 0; + using CharacterManagementUiController controller = + Assert.IsType( + CharacterManagementUiController.Bind( + host, + screen, + (_, _) => ++resolveCalls == 1 ? null : BuildRow(), + dialogs, + runtime.Bindings, + TestStrings())); + + Assert.Empty(controller.Rows); + controller.Tick(); + + Assert.Equal(3, controller.Rows.Count); + Assert.True(resolveCalls >= 4); + } + + private static CharacterManagementUiController.DialogStrings TestStrings() => + new( + name => $"WARNING! {name}\nType DELETE in the box below.", + "DELETE", + "Please Wait", + "Entering World"); + + private static ImportedLayout BuildScreen(bool includePreview = false) + { + var root = new ElementInfo + { + Id = CharacterManagementUiController.RootElementId, + Type = 3u, + Width = 800f, + Height = 600f, + }; + var list = new ElementInfo + { + Id = CharacterManagementUiController.ListElementId, + Type = 5u, + X = 42f, + Y = 212f, + Width = 160f, + Height = 320f, + }; + list.TemplateList.Add(new UiTemplateListEntry( + 0x21000004u, + 0x100003A5u)); + root.Children.Add(list); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.CreateElementId)); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.EnterElementId)); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.DeleteElementId)); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.RestoreElementId)); + if (includePreview) + { + root.Children.Add(new ElementInfo + { + Id = 0xDEADBEEFu, + Type = 0xDu, + Width = 100f, + Height = 100f, + }); + } + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + + private static ElementInfo ButtonInfo(uint id) => new() + { + Id = id, + Type = 1u, + Width = 100f, + Height = 30f, + }; + + private static UiElement BuildRow() => LayoutImporter.Build( + new ElementInfo + { + Id = 0x100003A5u, + Type = 1u, + Width = 160f, + Height = 16f, + }, + _ => (0u, 0, 0), + null).Root; + + private static IEnumerable Descendants(UiElement root) + { + yield return root; + foreach (UiElement child in root.Children) + foreach (UiElement descendant in Descendants(child)) + yield return descendant; + } + + private static UiButton DialogButton(ImportedLayout layout, uint id) => + Assert.IsType(layout.FindElement(id)); + + private static UiField Input(ImportedLayout layout) => + Assert.IsType(layout.FindElement( + RetailConfirmationTextInputDialogView.InputElementId)); + + private static string Message(ImportedLayout layout) => string.Join( + " ", + Assert.IsType(layout.FindElement(0x3Eu)) + .LinesProvider() + .Select(static line => line.Text)); + + private sealed class EnvironmentHarness : IDisposable + { + public EnvironmentHarness() + { + Host = new UiRoot { Width = 800f, Height = 600f }; + Screen = BuildScreen(); + Runtime = new FakeRuntime(); + Dialogs = new RetailDialogFactory(Host, type => + { + ImportedLayout layout = + RetailDialogFactoryTests.BuildDialogLayout(type); + DialogLayouts.Add((type, layout)); + return layout; + }); + Controller = Assert.IsType( + CharacterManagementUiController.Bind( + Host, + Screen, + static (_, _) => BuildRow(), + Dialogs, + Runtime.Bindings, + TestStrings())); + } + + public UiRoot Host { get; } + public ImportedLayout Screen { get; } + public FakeRuntime Runtime { get; } + public RetailDialogFactory Dialogs { get; } + public List<(RetailDialogType Type, ImportedLayout Layout)> DialogLayouts { get; } = []; + public CharacterManagementUiController Controller { get; } + + public UiButton Button(uint id) => + Assert.IsType(Screen.FindElement(id)); + + public ImportedLayout LastDialog(RetailDialogType type) => + DialogLayouts.Last(entry => entry.Type == type).Layout; + + public void Dispose() + { + Controller.Dispose(); + Dialogs.Dispose(); + } + } + + private sealed class FakeRuntime + { + private static readonly RuntimeGenerationToken Generation = new(7u); + + public FakeRuntime() + { + View.Entries = + [ + new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u), + new RuntimeCharacterSelectionEntry(1, 0x50000002u, "Zulu", 0u), + new RuntimeCharacterSelectionEntry(2, 0x50000003u, "Aaron (pending)", 1u), + ]; + View.Snapshot = Snapshot( + RuntimeCharacterSelectionLifecycle.AwaitingSelection, + revision: 1, + highlightedCharacterId: 0x50000001u, + buttons: ButtonsFor(0x50000001u)); + Bindings = new CharacterSelectionRuntimeBindings( + () => ProvideView ? View : null, + Highlight, + Enter, + RequestDelete, + ConfirmDelete, + Restore, + Cancel); + } + + public FakeView View { get; } = new(); + public CharacterSelectionRuntimeBindings Bindings { get; } + public bool ProvideView { get; set; } = true; + public int HighlightCalls { get; private set; } + public int EnterCalls { get; private set; } + public int ConfirmDeleteCalls { get; private set; } + public int CancelCalls { get; private set; } + + public void SetOperation(RuntimeCharacterSelectionOperation operation) + { + RuntimeCharacterSelectionButtons buttons = operation is + RuntimeCharacterSelectionOperation.DeleteRequested + or RuntimeCharacterSelectionOperation.DeleteAcknowledged + ? RuntimeCharacterSelectionButtons.None + : ButtonsFor(View.Snapshot.HighlightedCharacterId); + Update(snapshot => snapshot with + { + Operation = operation, + Buttons = buttons, + }); + } + + public void ReplaceRoster( + RuntimeCharacterSelectionEntry[] entries, + uint highlightedCharacterId) + { + View.Entries = entries; + Update(snapshot => snapshot with + { + RosterCount = entries.Length, + HighlightedCharacterId = highlightedCharacterId, + HighlightedDisplayIndex = Array.FindIndex( + entries, + entry => entry.CharacterId == highlightedCharacterId), + PendingDeleteCharacterId = 0u, + Operation = RuntimeCharacterSelectionOperation.None, + Buttons = ButtonsFor(highlightedCharacterId), + }); + } + + public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) => + Update(snapshot => snapshot with { Lifecycle = lifecycle }); + + public void SetError(string message) => Update(snapshot => snapshot with + { + Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection, + Error = new RuntimeCharacterSelectionError( + 1u, + AcDream.Core.Net.Messages.CharacterError.Code.Logon, + message), + PendingDeleteCharacterId = 0u, + Operation = RuntimeCharacterSelectionOperation.None, + }); + + private RuntimeCommandResult Highlight(uint characterId) + { + HighlightCalls++; + int index = Array.FindIndex( + View.Entries, + entry => entry.CharacterId == characterId); + if (index < 0) + return Result(RuntimeCommandStatus.Rejected); + Update(snapshot => snapshot with + { + HighlightedCharacterId = characterId, + HighlightedDisplayIndex = index, + Buttons = ButtonsFor(characterId), + }); + return Result(RuntimeCommandStatus.Accepted, characterId); + } + + private RuntimeCommandResult Enter() + { + EnterCalls++; + Update(snapshot => snapshot with + { + Lifecycle = RuntimeCharacterSelectionLifecycle.EnteringWorld, + Error = null, + }); + return Result( + RuntimeCommandStatus.Accepted, + View.Snapshot.HighlightedCharacterId); + } + + private RuntimeCommandResult RequestDelete() + { + uint id = View.Snapshot.HighlightedCharacterId; + Update(snapshot => snapshot with + { + PendingDeleteCharacterId = id, + Error = null, + }); + return Result(RuntimeCommandStatus.Accepted, id); + } + + private RuntimeCommandResult ConfirmDelete() + { + ConfirmDeleteCalls++; + uint id = View.Snapshot.HighlightedCharacterId; + Update(snapshot => snapshot with + { + PendingDeleteCharacterId = 0u, + Operation = RuntimeCharacterSelectionOperation.DeleteRequested, + Buttons = RuntimeCharacterSelectionButtons.None, + }); + return Result(RuntimeCommandStatus.Accepted, id); + } + + private RuntimeCommandResult Restore() + { + uint id = View.Snapshot.HighlightedCharacterId; + Update(snapshot => snapshot with + { + LastRestoreRequestedCharacterId = id, + Operation = RuntimeCharacterSelectionOperation.RestoreRequested, + Buttons = new RuntimeCharacterSelectionButtons( + false, + false, + false, + false, + true), + }); + return Result(RuntimeCommandStatus.Accepted, id); + } + + private RuntimeCommandResult Cancel() + { + CancelCalls++; + Update(snapshot => snapshot with + { + PendingDeleteCharacterId = 0u, + Error = null, + Buttons = ButtonsFor(snapshot.HighlightedCharacterId), + }); + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId) + { + RuntimeCharacterSelectionEntry? selected = View.Entries + .Cast() + .FirstOrDefault(entry => entry?.CharacterId == characterId); + if (selected is null) + return RuntimeCharacterSelectionButtons.None; + if (selected.Value.IsPendingDelete) + { + return new RuntimeCharacterSelectionButtons( + false, + false, + true, + false, + true); + } + return new RuntimeCharacterSelectionButtons( + true, + true, + false, + true, + false); + } + + private void Update( + Func update) + { + RuntimeCharacterSelectionSnapshot current = View.Snapshot; + RuntimeCharacterSelectionSnapshot next = update(current); + View.Snapshot = next with { Revision = current.Revision + 1 }; + } + + private RuntimeCharacterSelectionSnapshot Snapshot( + RuntimeCharacterSelectionLifecycle lifecycle, + long revision, + uint highlightedCharacterId, + RuntimeCharacterSelectionButtons buttons) => new( + Generation, + lifecycle, + revision, + "account", + SlotCount: 5, + RosterCount: View.Entries.Length, + highlightedCharacterId, + HighlightedDisplayIndex: Array.FindIndex( + View.Entries, + entry => entry.CharacterId == highlightedCharacterId), + PendingDeleteCharacterId: 0u, + LastRestoreRequestedCharacterId: 0u, + Operation: RuntimeCharacterSelectionOperation.None, + Error: null, + buttons); + + private static RuntimeCommandResult Result( + RuntimeCommandStatus status, + uint objectId = 0u) => new(status, Generation, objectId); + } + + private sealed class FakeView : IRuntimeCharacterSelectionView + { + public RuntimeCharacterSelectionEntry[] Entries { get; set; } = []; + public RuntimeCharacterSelectionSnapshot Snapshot { get; set; } + + public bool TryGetAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) + { + if ((uint)displayIndex >= (uint)Entries.Length) + { + character = default; + return false; + } + character = Entries[displayIndex]; + return true; + } + + public bool TryGet( + uint characterId, + out RuntimeCharacterSelectionEntry character) + { + int index = Array.FindIndex( + Entries, + entry => entry.CharacterId == characterId); + if (index < 0) + { + character = default; + return false; + } + character = Entries[index]; + return true; + } + + public void Visit(IRuntimeCharacterSelectionVisitor visitor) + { + foreach (RuntimeCharacterSelectionEntry character in Entries) + visitor.Visit(in character); + } + + public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) => + NoopDisposable.Instance; + } + + private sealed class NoopDisposable : IDisposable + { + public static NoopDisposable Instance { get; } = new(); + public void Dispose() { } + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index c06ae755..a77d51ff 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -31,6 +31,8 @@ public class DatWidgetFactoryTests [Theory] [InlineData(0x13u)] // ConfirmationDialog (catalog root 0x15) + [InlineData(0x15u)] // ConfirmationTextInputDialog (catalog root 0x2C) + [InlineData(0x17u)] // MessageDialog (catalog root 0x24) [InlineData(0x19u)] // WaitDialog (catalog root 0x31 — OP8 #396's live // crash: unmapped type built a plain UiDatElement and // RetailWaitDialogView's ctor threw out of OnClick) diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 75455e7b..5e495c0c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -378,6 +378,68 @@ public sealed class RetailDialogFactoryTests Assert.Equal("text", data.GetString(RetailDialogProperty.Message)); } + [Fact] + public void MessageDialog_UsesAuthoredOkButtonAndReturnsThroughFactoryCallback() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>(); + using var factory = new RetailDialogFactory(root, type => + { + ImportedLayout layout = BuildDialogLayout(type); + layouts.Add((type, layout)); + return layout; + }); + bool completed = false; + + factory.MakeMessage("Character selection failed.", _ => completed = true); + + (RetailDialogType type, ImportedLayout layout) = Assert.Single(layouts); + Assert.Equal(RetailDialogType.Message, type); + Assert.Equal("Character selection failed.", Message(layout)); + Button(layout, RetailMessageDialogView.OkButtonId).OnClick!(); + Assert.True(completed); + Assert.False(factory.IsOpen); + Assert.Null(root.Modal); + } + + [Fact] + public void ConfirmationTextInput_AcceptsTypedResultAndRejectsWithEmptyResult() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>(); + using var factory = new RetailDialogFactory(root, type => + { + ImportedLayout layout = BuildDialogLayout(type); + layouts.Add((type, layout)); + return layout; + }); + var results = new List(); + + factory.MakeConfirmationTextInput( + "Type DELETE.", + data => results.Add( + data.GetString(RetailDialogProperty.TextInputResult) ?? "")); + ImportedLayout accepted = layouts[^1].Layout; + var field = Assert.IsType( + accepted.FindElement(RetailConfirmationTextInputDialogView.InputElementId)); + Assert.Same(field, root.KeyboardFocus); + field.SetText("delete"); + Button(accepted, RetailConfirmationTextInputDialogView.AcceptButtonId).OnClick!(); + + factory.MakeConfirmationTextInput( + "Type DELETE.", + data => results.Add( + data.GetString(RetailDialogProperty.TextInputResult) ?? "")); + ImportedLayout rejected = layouts[^1].Layout; + UiDialogRoot rejectedRoot = Assert.IsType(rejected.Root); + Assert.NotNull(rejectedRoot.Cancel); + rejectedRoot.Cancel!(); + + Assert.Equal(["delete", ""], results); + Assert.False(factory.IsOpen); + Assert.Null(root.KeyboardFocus); + } + private static RetailDialogFactory CreateFactory( UiRoot root, List layouts) @@ -401,4 +463,91 @@ public sealed class RetailDialogFactoryTests private static string Message(ImportedLayout layout) => string.Join(" ", Assert.IsType(layout.FindElement( RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)); + + internal static ImportedLayout BuildDialogLayout(RetailDialogType type) + { + uint rootId = RetailDialogFactory.RootElementId(type); + uint rootType = type switch + { + RetailDialogType.Message => 0x17u, + RetailDialogType.ConfirmationTextInput => 0x15u, + RetailDialogType.Wait => 0x19u, + _ => 0x13u, + }; + var root = new ElementInfo + { + Id = rootId, + Type = rootType, + Width = 800f, + Height = 600f, + }; + var popup = new ElementInfo + { + Id = 0x3Du, + Type = 3u, + Width = 400f, + Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f, + }; + popup.Children.Add(new ElementInfo + { + Id = 0x3Eu, + Type = 12u, + X = 15f, + Y = 15f, + Width = 370f, + Height = 18f, + }); + if (type == RetailDialogType.Message) + { + popup.Children.Add(new ElementInfo + { + Id = RetailMessageDialogView.OkButtonId, + Type = 1u, + X = 160f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + } + else if (type == RetailDialogType.ConfirmationTextInput) + { + var field = new ElementInfo + { + Id = RetailConfirmationTextInputDialogView.InputElementId, + Type = 12u, + X = 4f, + Y = 43f, + Width = 152f, + Height = 16f, + }; + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + direct.Properties.Values[0x16u] = new UiPropertyValue + { + Kind = UiPropertyKind.Bool, + BoolValue = true, + }; + field.States.Add(UiStateInfo.DirectStateId, direct); + popup.Children.Add(field); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationTextInputDialogView.AcceptButtonId, + Type = 1u, + X = 80f, + Y = 78f, + Width = 80f, + Height = 32f, + }); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationTextInputDialogView.RejectButtonId, + Type = 1u, + X = 240f, + Y = 78f, + Width = 80f, + Height = 32f, + }); + } + root.Children.Add(popup); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } } diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 4eab1d72..8baaaa38 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -30,6 +30,29 @@ public class UiButtonTests Assert.Equal((17, 9), clicked); } + [Fact] + public void DoubleClick_IsOptInAndDisabledButtonsSwallowWithoutInvoking() + { + int activations = 0; + var button = new UiButton( + new ElementInfo { Type = 1, Width = 46, Height = 18 }, + NoTex); + var doubleClick = new UiEvent( + 0, + button, + UiEventType.DoubleClick); + + Assert.False(button.OnEvent(doubleClick)); + + button.OnDoubleClick = () => activations++; + Assert.True(button.OnEvent(doubleClick)); + Assert.Equal(1, activations); + + button.Enabled = false; + Assert.True(button.OnEvent(doubleClick)); + Assert.Equal(1, activations); + } + [Fact] public void PointerDownAndUp_InvokeDistinctTransitionHandlers() { From 3f68895120d3ad52cd418582b3fd812486758a85 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 20:36:11 +0200 Subject: [PATCH 042/138] fix(launcher): harden installer transactions --- .github/workflows/headless-portability.yml | 33 +- AcDream.slnx | 1 + docs/architecture/acdream-architecture.md | 8 +- src/AcDream.Bake/BakeCommandLine.cs | 10 +- src/AcDream.Bake/BakeOutputTransaction.cs | 23 +- src/AcDream.Bake/Program.cs | 6 + .../Installation/BakeOutputStagingContract.cs | 64 ++++ .../Installation/BakeProgressProtocol.cs | 121 ++++++++ .../Installation/InstallerTransactionLease.cs | 60 ++++ .../LauncherInstallRecordStore.cs | 175 ++++++++++- .../Installation/LauncherInstaller.cs | 91 +++--- src/AcDream.Launcher/AcDream.Launcher.csproj | 19 +- .../BakeOutputTransactionTests.cs | 18 ++ .../BakeProgressCliTests.cs | 10 + ...e.Tests.Fixtures.InstallLeaseHolder.csproj | 11 + .../Program.cs | 24 ++ .../AcDream.Launcher.Core.Tests.csproj | 6 + .../Installation/BakeProgressProtocolTests.cs | 109 +++++++ .../LauncherInstallRecordStoreTests.cs | 131 ++++++++ .../Installation/LauncherInstallerTests.cs | 283 ++++++++++++++++++ .../LauncherProjectBoundaryTests.cs | 22 ++ 21 files changed, 1164 insertions(+), 61 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs create mode 100644 src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs create mode 100644 src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 02cc292c..757827b9 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -172,17 +172,34 @@ jobs: dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Publish the self-contained Linux launcher - if: runner.os == 'Linux' + - name: Publish the self-contained launcher distribution shell: pwsh run: | + $rid = if ($IsWindows) { "win-x64" } else { "linux-x64" } dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj ` -c Release ` - -r linux-x64 ` - -o artifacts/acdream-launcher-linux-x64 + -r $rid ` + -o "artifacts/acdream-launcher-$rid" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Verify self-contained property and artifact execution + - name: Verify self-contained Windows launcher and bake artifacts + if: runner.os == 'Windows' + shell: pwsh + run: | + $root = "artifacts/acdream-launcher-win-x64" + if (-not (Test-Path -LiteralPath "$root/acdream-launcher.exe" -PathType Leaf)) { throw "launcher executable missing" } + if (-not (Test-Path -LiteralPath "$root/acdream-bake.exe" -PathType Leaf)) { throw "bake executable missing" } + if (Test-Path -LiteralPath "$root/acdream-launcher.dll") { throw "launcher is not single-file" } + if (Test-Path -LiteralPath "$root/acdream-bake.dll") { throw "bake is not single-file" } + $env:DOTNET_ROOT = "Z:\definitely-not-installed" + $env:DOTNET_ROOT_X64 = "Z:\definitely-not-installed" + $env:DOTNET_MULTILEVEL_LOOKUP = "0" + & "$root/acdream-launcher.exe" --verify-publish + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "$root/acdream-bake.exe" --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify self-contained Linux launcher and bake artifacts if: runner.os == 'Linux' shell: bash run: | @@ -195,11 +212,17 @@ jobs: -getProperty:SelfContained | tr -d '\r\n ') test "$self_contained" = true test -x "$root/acdream-launcher" + test -x "$root/acdream-bake" test ! -f "$root/acdream-launcher.dll" + test ! -f "$root/acdream-bake.dll" DOTNET_ROOT=/definitely-not-installed \ DOTNET_ROOT_X64=/definitely-not-installed \ DOTNET_MULTILEVEL_LOOKUP=0 \ "$root/acdream-launcher" --verify-publish + DOTNET_ROOT=/definitely-not-installed \ + DOTNET_ROOT_X64=/definitely-not-installed \ + DOTNET_MULTILEVEL_LOOKUP=0 \ + "$root/acdream-bake" --help linux-graphical: runs-on: ubuntu-latest diff --git a/AcDream.slnx b/AcDream.slnx index 0771d163..a332b1db 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -27,6 +27,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2b22c7dd..34c9d23b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -293,7 +293,11 @@ src/ Installation/ -> portable four-DAT validation, Windows retail path discovery, versioned JSONL bake-process orchestration, and atomic SHA/size/tool-version - install-record verification and recovery + install-record verification and recovery; one + OS-handle lease serializes recovery/install per + DataDirectory, and only exact adjacent + `..acdream-bake..tmp` files are + transaction-owned crash residue -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell @@ -301,6 +305,8 @@ src/ including the first-run DAT/bake wizard -> references Launcher.Core only (Platform transitively); it never owns a second profile, process, status, or credential state graph + -> every per-RID publish composes the separately published self-contained + `acdream-bake` executable beside the launcher without a project edge -> Linux launcher/probe/headless flows remain portable; graphical-client actions are explicitly disabled until Modern Runtime Slice L resumes diff --git a/src/AcDream.Bake/BakeCommandLine.cs b/src/AcDream.Bake/BakeCommandLine.cs index e5c76139..ffdfb615 100644 --- a/src/AcDream.Bake/BakeCommandLine.cs +++ b/src/AcDream.Bake/BakeCommandLine.cs @@ -15,7 +15,15 @@ internal static class BakeCommandLine internal const string Usage = "usage: acdream-bake --dat-dir [--out ] " + "[--ids 0xId,0xId,...] [--landblocks 0xId,...] " - + "[--threads ] [--progress-json]"; + + "[--threads ] [--progress-json]\n" + + " acdream-bake --help"; + + public static bool IsHelpRequest(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + return args.Count == 1 + && args[0] is "--help" or "-h"; + } public static bool TryParse( IReadOnlyList args, diff --git a/src/AcDream.Bake/BakeOutputTransaction.cs b/src/AcDream.Bake/BakeOutputTransaction.cs index 0bc58e16..54416ab5 100644 --- a/src/AcDream.Bake/BakeOutputTransaction.cs +++ b/src/AcDream.Bake/BakeOutputTransaction.cs @@ -9,6 +9,8 @@ namespace AcDream.Bake; /// public static class BakeOutputTransaction { + internal const string StagingMarker = ".acdream-bake."; + public static TResult WriteValidateAndPublish( string destinationPath, Func writeTemporary, @@ -25,9 +27,7 @@ public static class BakeOutputTransaction throw new InvalidOperationException("destination has no parent directory"); Directory.CreateDirectory(directory); - string temporaryPath = Path.Combine( - directory, - $".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp"); + string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid()); try { @@ -60,4 +60,21 @@ public static class BakeOutputTransaction } } } + + /// + /// Exact adjacent staging-name contract shared, by documentation and + /// conformance tests, with Launcher.Core. Keeping this tiny contract in + /// each BCL-facing assembly avoids an otherwise inverted project edge. + /// + internal static string CreateStagingPath(string destinationPath, Guid transactionId) + { + string fullDestination = Path.GetFullPath(destinationPath); + string directory = Path.GetDirectoryName(fullDestination) + ?? throw new InvalidOperationException( + "destination has no parent directory"); + return Path.Combine( + directory, + $".{Path.GetFileName(fullDestination)}{StagingMarker}" + + $"{transactionId:N}.tmp"); + } } diff --git a/src/AcDream.Bake/Program.cs b/src/AcDream.Bake/Program.cs index 4abfc862..5aba455e 100644 --- a/src/AcDream.Bake/Program.cs +++ b/src/AcDream.Bake/Program.cs @@ -9,6 +9,12 @@ using AcDream.Bake; // // Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5. +if (BakeCommandLine.IsHelpRequest(args)) +{ + Console.Out.WriteLine(BakeCommandLine.Usage); + return 0; +} + if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command)) { return 2; diff --git a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs new file mode 100644 index 00000000..a6ae15b4 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs @@ -0,0 +1,64 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Exact adjacent temporary-file contract emitted by AcDream.Bake's +/// BakeOutputTransaction. This class intentionally has no Bake project +/// dependency: both sides pin the same documented format with conformance +/// tests so Launcher.Core remains BCL-only. +/// +internal static class BakeOutputStagingContract +{ + internal const string StagingMarker = ".acdream-bake."; + private const string Suffix = ".tmp"; + + internal static string CreateStagingPath( + string destinationPath, + Guid transactionId) + { + string fullDestination = Path.GetFullPath(destinationPath); + string directory = Path.GetDirectoryName(fullDestination) + ?? throw new InvalidOperationException( + "The prepared package path has no parent directory."); + return Path.Combine( + directory, + $".{Path.GetFileName(fullDestination)}{StagingMarker}" + + $"{transactionId:N}{Suffix}"); + } + + internal static bool IsOwnedStagingFileName( + string fileName, + string destinationFileName) + { + string prefix = $".{destinationFileName}{StagingMarker}"; + if (!fileName.StartsWith(prefix, StringComparison.Ordinal) + || !fileName.EndsWith(Suffix, StringComparison.Ordinal) + || fileName.Length != prefix.Length + 32 + Suffix.Length) + { + return false; + } + + ReadOnlySpan transaction = fileName.AsSpan(prefix.Length, 32); + return Guid.TryParseExact(transaction, "N", out _); + } + + internal static void DeleteOwnedStagingFiles(string destinationPath) + { + string fullDestination = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullDestination); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return; + } + + string destinationFileName = Path.GetFileName(fullDestination); + foreach (string candidate in Directory.EnumerateFiles(directory)) + { + if (IsOwnedStagingFileName( + Path.GetFileName(candidate), + destinationFileName)) + { + LauncherInstallRecordStore.TryDelete(candidate); + } + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs new file mode 100644 index 00000000..65d5eb92 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs @@ -0,0 +1,121 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Strict state machine for known v1 bake events. Human output, unknown v1 +/// events, and future versions are deliberately transparent; known v1 events +/// cannot be reordered, duplicated, or appended after the first terminal. +/// +internal sealed class BakeProgressProtocol +{ + private BakeProgressProtocolState _state; + + internal BakeStartedEvent? Started { get; private set; } + + internal BakeCompletedEvent? Completed { get; private set; } + + internal BakeErrorEvent? Error { get; private set; } + + internal string? Violation { get; private set; } + + internal bool Observe(BakeProgressEvent progressEvent) + { + ArgumentNullException.ThrowIfNull(progressEvent); + if (Violation is not null) + { + return false; + } + + switch (progressEvent) + { + case BakeHumanOutputEvent: + case UnknownBakeProgressEvent: + case FutureBakeProgressEvent: + return true; + case MalformedBakeProgressEvent malformed: + Reject($"Malformed bake progress: {malformed.Reason}"); + return false; + case BakeStartedEvent started: + if (_state != BakeProgressProtocolState.AwaitingStarted) + { + Reject(_state == BakeProgressProtocolState.Running + ? "The bake protocol emitted more than one v1 started event." + : "The bake protocol emitted a known event after its terminal event."); + return false; + } + + Started = started; + _state = BakeProgressProtocolState.Running; + return true; + case BakeWorkProgressEvent: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("progress")); + return false; + } + + return true; + case BakeCompletedEvent completed: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("completed")); + return false; + } + + Completed = completed; + _state = BakeProgressProtocolState.Completed; + return true; + case BakeErrorEvent error: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("error")); + return false; + } + + Error = error; + _state = BakeProgressProtocolState.Error; + return true; + default: + Reject("The bake protocol emitted an unsupported known event."); + return false; + } + } + + internal void CompleteInput() + { + if (Violation is not null) + { + return; + } + + if (_state == BakeProgressProtocolState.AwaitingStarted) + { + Reject("The bake protocol did not emit a v1 started event first."); + } + else if (_state == BakeProgressProtocolState.Running) + { + Reject("The bake protocol ended without exactly one terminal event."); + } + } + + private string KnownEventStateViolation(string eventName) => _state switch + { + BakeProgressProtocolState.AwaitingStarted => + $"The bake protocol emitted v1 {eventName} before v1 started.", + BakeProgressProtocolState.Running => + $"The bake protocol emitted an invalid v1 {eventName} event.", + _ => "The bake protocol emitted a known event after its terminal event.", + }; + + private void Reject(string message) + { + Violation ??= message; + } + + private enum BakeProgressProtocolState + { + AwaitingStarted, + Running, + Completed, + Error, + } +} diff --git a/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs new file mode 100644 index 00000000..1fc7ec8e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs @@ -0,0 +1,60 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Cross-process ownership for every mutation or recovery of one launcher +/// DataDirectory. The persistent lock pathname is harmless; exclusivity is +/// owned by the open OS handle and therefore disappears if the process dies. +/// +internal sealed class InstallerTransactionLease : IAsyncDisposable +{ + internal const string LockFileName = ".install.lock"; + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + private readonly FileStream _stream; + + private InstallerTransactionLease(FileStream stream) + { + _stream = stream; + } + + internal static string GetLockPath(string dataDirectory) => + Path.Combine(Path.GetFullPath(dataDirectory), LockFileName); + + internal static async ValueTask AcquireAsync( + string dataDirectory, + CancellationToken cancellationToken = default) + { + string lockPath = GetLockPath(dataDirectory); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The installer lock path has no parent directory.")); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var stream = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + return new InstallerTransactionLease(stream); + } + catch (IOException) + { + await Task.Delay(RetryDelay, cancellationToken) + .ConfigureAwait(false); + } + } + } + + public ValueTask DisposeAsync() + { + _stream.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index c6815d1d..f88dba2f 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -54,10 +54,12 @@ public sealed class LauncherInstallRecordStore FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); } - public string RecordPath => Path.Combine(_paths.DataDirectory, "install.json"); + public string DataDirectory => Path.GetFullPath(_paths.DataDirectory); + + public string RecordPath => Path.Combine(DataDirectory, "install.json"); public string PreparedAssetPath => Path.Combine( - _paths.DataDirectory, + DataDirectory, "pak", "acdream.pak"); @@ -66,6 +68,18 @@ public sealed class LauncherInstallRecordStore public async Task LoadAndVerifyAsync( CancellationToken cancellationToken = default) + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + DataDirectory, + cancellationToken) + .ConfigureAwait(false); + return await LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + internal async Task LoadAndVerifyUnderLeaseAsync( + CancellationToken cancellationToken = default) { if (!File.Exists(RecordPath)) { @@ -85,11 +99,20 @@ public sealed class LauncherInstallRecordStore FileShare.Read, bufferSize: 4096, options: FileOptions.Asynchronous | FileOptions.SequentialScan); - record = await JsonSerializer.DeserializeAsync( + using JsonDocument document = await JsonDocument.ParseAsync( stream, - SerializerOptions, - cancellationToken) + cancellationToken: cancellationToken) .ConfigureAwait(false); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("version", out JsonElement version) + || !version.TryGetInt32(out _)) + { + return Invalid( + "The install record must contain an explicit integer version."); + } + + record = root.Deserialize(SerializerOptions); } catch (OperationCanceledException) { @@ -108,7 +131,9 @@ public sealed class LauncherInstallRecordStore return Invalid("The install record is empty."); } - string? contractError = ValidateRecordContract(record); + string? contractError = ValidateRecordContract( + record, + requireCanonicalSerializedPaths: true); if (contractError is not null) { return Invalid(contractError); @@ -158,9 +183,25 @@ public sealed class LauncherInstallRecordStore public async Task SaveAtomicallyAsync( LauncherInstallRecord record, CancellationToken cancellationToken = default) + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + DataDirectory, + cancellationToken) + .ConfigureAwait(false); + await SaveAtomicallyUnderLeaseAsync(record, cancellationToken) + .ConfigureAwait(false); + } + + internal async Task SaveAtomicallyUnderLeaseAsync( + LauncherInstallRecord record, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(record); - string? contractError = ValidateRecordContract(record); + LauncherInstallRecord normalized = NormalizeForSave(record); + string? contractError = ValidateRecordContract( + normalized, + requireCanonicalSerializedPaths: true); if (contractError is not null) { throw new InvalidDataException(contractError); @@ -186,7 +227,7 @@ public sealed class LauncherInstallRecordStore { await JsonSerializer.SerializeAsync( stream, - record, + normalized, SerializerOptions, cancellationToken) .ConfigureAwait(false); @@ -203,7 +244,9 @@ public sealed class LauncherInstallRecordStore } } - private string? ValidateRecordContract(LauncherInstallRecord record) + private string? ValidateRecordContract( + LauncherInstallRecord record, + bool requireCanonicalSerializedPaths) { if (record.Version != LauncherInstallRecord.CurrentRecordVersion) { @@ -226,7 +269,23 @@ public sealed class LauncherInstallRecordStore return "The install record contains an invalid SHA-256 digest."; } + if (string.IsNullOrWhiteSpace(record.PreparedAssetPath)) + { + return "The prepared asset path is missing."; + } + + if (string.IsNullOrWhiteSpace(record.DatDirectory)) + { + return "The DAT directory path is missing."; + } + string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath); + if (requireCanonicalSerializedPaths + && !Path.IsPathFullyQualified(record.PreparedAssetPath)) + { + return "The prepared asset path must be absolute."; + } + string recordedPreparedPath; try { @@ -245,11 +304,87 @@ public sealed class LauncherInstallRecordStore + "DataDirectory/pak/acdream.pak path."; } + if (requireCanonicalSerializedPaths + && !CanonicalSpellingEquals( + record.PreparedAssetPath, + recordedPreparedPath, + trimEndingSeparator: false)) + { + return "The prepared asset path is not canonical."; + } + + if (requireCanonicalSerializedPaths + && !Path.IsPathFullyQualified(record.DatDirectory)) + { + return "The DAT directory path must be absolute."; + } + DatDirectoryValidation datValidation = _datDirectories.Validate(record.DatDirectory); - return datValidation.IsValid - ? null - : datValidation.Message + FormatMissing(datValidation.MissingFileNames); + if (!datValidation.IsValid) + { + return datValidation.Message + + FormatMissing(datValidation.MissingFileNames); + } + + return requireCanonicalSerializedPaths + && !CanonicalSpellingEquals( + record.DatDirectory, + datValidation.Directory, + trimEndingSeparator: true) + ? "The DAT directory path is not canonical." + : null; + } + + private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record) + { + if (record.Version != LauncherInstallRecord.CurrentRecordVersion) + { + throw new InvalidDataException( + $"Install record version {record.Version} is not supported."); + } + + if (string.IsNullOrWhiteSpace(record.PreparedAssetPath)) + { + throw new InvalidDataException("The prepared asset path is missing."); + } + + DatDirectoryValidation datValidation = + _datDirectories.Validate(record.DatDirectory); + if (!datValidation.IsValid) + { + throw new InvalidDataException( + datValidation.Message + + FormatMissing(datValidation.MissingFileNames)); + } + + string recordedPreparedPath; + try + { + recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + throw new InvalidDataException( + $"The prepared asset path is invalid: {ex.Message}", + ex); + } + + if (!PathsEqual(recordedPreparedPath, PreparedAssetPath)) + { + throw new InvalidDataException( + "The install record does not point to the launcher's canonical " + + "DataDirectory/pak/acdream.pak path."); + } + + return record with + { + Version = LauncherInstallRecord.CurrentRecordVersion, + DatDirectory = datValidation.Directory, + PreparedAssetPath = Path.GetFullPath(PreparedAssetPath), + }; } private async Task VerifyFileAsync( @@ -317,6 +452,22 @@ public sealed class LauncherInstallRecordStore ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + private static bool CanonicalSpellingEquals( + string serialized, + string canonical, + bool trimEndingSeparator) + { + string candidate = trimEndingSeparator + ? Path.TrimEndingDirectorySeparator(serialized) + : serialized; + return string.Equals( + candidate, + canonical, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + } + internal static void TryDelete(string path) { try diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 88a29074..488f03f5 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -112,11 +112,26 @@ public sealed class LauncherInstaller : ILauncherInstaller public async Task LoadExistingAsync( CancellationToken cancellationToken = default) { - InstallRecordVerification verification = await _recordStore - .LoadAndVerifyAsync(cancellationToken) - .ConfigureAwait(false); - _verifiedRecord = verification.Record; - return verification; + await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + _recordStore.DataDirectory, + cancellationToken) + .ConfigureAwait(false); + BakeOutputStagingContract.DeleteOwnedStagingFiles( + _recordStore.PreparedAssetPath); + InstallRecordVerification verification = await _recordStore + .LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = verification.Record; + return verification; + } + finally + { + _installGate.Release(); + } } public async Task InstallAsync( @@ -135,6 +150,11 @@ public sealed class LauncherInstaller : ILauncherInstaller await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + _recordStore.DataDirectory, + cancellationToken) + .ConfigureAwait(false); return await InstallCoreAsync( datDirectory, threads, @@ -177,13 +197,11 @@ public sealed class LauncherInstaller : ILauncherInstaller string outputPath = _recordStore.PreparedAssetPath; string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); - if (_verifiedRecord is null) - { - InstallRecordVerification existing = await _recordStore - .LoadAndVerifyAsync(cancellationToken) - .ConfigureAwait(false); - _verifiedRecord = existing.Record; - } + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + InstallRecordVerification existing = await _recordStore + .LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = existing.Record; Directory.CreateDirectory( Path.GetDirectoryName(outputPath) @@ -201,19 +219,14 @@ public sealed class LauncherInstaller : ILauncherInstaller } var parser = new BakeProgressJsonlParser(); - BakeStartedEvent? started = null; - BakeCompletedEvent? completed = null; - string? protocolError = null; - string? childError = null; + var protocol = new BakeProgressProtocol(); void Observe(BakeProgressEvent progressEvent) { + bool accepted = protocol.Observe(progressEvent); switch (progressEvent) { - case BakeStartedEvent value: - started = value; - break; - case BakeWorkProgressEvent value: + case BakeWorkProgressEvent value when accepted: LauncherInstallPhase phase = value.Phase switch { "mesh" => LauncherInstallPhase.BakingMeshes, @@ -231,18 +244,13 @@ public sealed class LauncherInstaller : ILauncherInstaller value.Failures, value.EtaSeconds); break; - case BakeCompletedEvent value: - completed = value; - break; - case BakeErrorEvent value: - childError = value.Message; + case BakeErrorEvent value when accepted: Report( progress, LauncherInstallPhase.Failed, $"Bake tool error: {value.Message}"); break; case MalformedBakeProgressEvent value: - protocolError ??= value.Reason; Report( progress, LauncherInstallPhase.Failed, @@ -278,34 +286,35 @@ public sealed class LauncherInstaller : ILauncherInstaller { Observe(progressEvent); } + protocol.CompleteInput(); cancellationToken.ThrowIfCancellationRequested(); + if (protocol.Violation is not null) + { + throw new LauncherInstallException(protocol.Violation); + } + if (processResult.ExitCode != 0) { throw new LauncherInstallException( BuildChildFailure( processResult.ExitCode, - childError, + protocol.Error?.Message, processResult.StandardError)); } - if (!string.IsNullOrWhiteSpace(childError)) + if (protocol.Error is not null) { throw new LauncherInstallException( - $"The bake tool reported an error: {childError}"); - } - - if (protocolError is not null) - { - throw new LauncherInstallException( - $"The bake tool emitted malformed JSON progress: {protocolError}"); + $"The bake tool reported an error: {protocol.Error.Message}"); } + BakeStartedEvent? started = protocol.Started; + BakeCompletedEvent? completed = protocol.Completed; if (started is null || completed is null) { throw new LauncherInstallException( - "The bake tool exited without the required v1 started/completed " - + "progress records."); + "The bake protocol did not finish with a v1 completed event."); } if (started.BakeToolVersion != completed.BakeToolVersion @@ -355,7 +364,9 @@ public sealed class LauncherInstaller : ILauncherInstaller progress, LauncherInstallPhase.SavingRecord, "Saving the verified install record..."); - await _recordStore.SaveAtomicallyAsync(record, cancellationToken) + await _recordStore.SaveAtomicallyUnderLeaseAsync( + record, + cancellationToken) .ConfigureAwait(false); _verifiedRecord = record; @@ -391,6 +402,10 @@ public sealed class LauncherInstaller : ILauncherInstaller throw new LauncherInstallException("Installation failed.", ex); } + finally + { + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + } } private bool PreservePreviousPackage(string outputPath, string backupPath) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index a4c20260..63fa57ff 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -10,7 +10,8 @@ true true true - true + true + true @@ -22,4 +23,20 @@ + + + + + <_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir) + <_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir) + + + diff --git a/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs b/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs index 4d397817..ea872021 100644 --- a/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs +++ b/tests/AcDream.Bake.Tests/BakeOutputTransactionTests.cs @@ -23,6 +23,24 @@ public sealed class BakeOutputTransactionTests : IDisposable } } + [Fact] + public void StagingPathUsesTheDocumentedLauncherRecoveryContract() + { + string destination = Path.Combine(_directory, "pak", "acdream.pak"); + Guid transaction = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef"); + + string staging = BakeOutputTransaction.CreateStagingPath( + destination, + transaction); + + Assert.Equal( + Path.Combine( + _directory, + "pak", + ".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"), + staging); + } + [Fact] public void Publish_ReplacesExistingDestinationOnlyAfterValidation() { diff --git a/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs b/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs index 7f3915fd..5f8e0658 100644 --- a/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs +++ b/tests/AcDream.Bake.Tests/BakeProgressCliTests.cs @@ -5,6 +5,16 @@ namespace AcDream.Bake.Tests; public sealed class BakeProgressCliTests { + [Theory] + [InlineData("--help")] + [InlineData("-h")] + public void HelpIsAZeroDatArgumentProbe(string argument) + { + Assert.True(BakeCommandLine.IsHelpRequest([argument])); + Assert.False(BakeCommandLine.IsHelpRequest([argument, "extra"])); + Assert.Contains("--help", BakeCommandLine.Usage, StringComparison.Ordinal); + } + [Fact] public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory() { diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj new file mode 100644 index 00000000..bc7176f0 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + latest + false + true + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs new file mode 100644 index 00000000..73207808 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -0,0 +1,24 @@ +if (args.Length != 3) +{ + return 2; +} + +string lockPath = Path.GetFullPath(args[0]); +string stagingPath = Path.GetFullPath(args[1]); +string readyPath = Path.GetFullPath(args[2]); +Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException("lock path has no parent")); +Directory.CreateDirectory( + Path.GetDirectoryName(stagingPath) + ?? throw new InvalidOperationException("staging path has no parent")); + +using var lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); +File.WriteAllText(stagingPath, "abandoned bake staging"); +File.WriteAllText(readyPath, "ready"); +await Task.Delay(Timeout.InfiniteTimeSpan); +return 0; diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj index 6f77e682..78fa3e4f 100644 --- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj +++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj @@ -19,5 +19,11 @@ + + + false + true + diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs new file mode 100644 index 00000000..438ced97 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProgressProtocolTests.cs @@ -0,0 +1,109 @@ +using AcDream.Launcher.Core.Installation; + +namespace AcDream.Launcher.Core.Tests.Installation; + +public sealed class BakeProgressProtocolTests +{ + [Fact] + public void OneStartedProgressAndCompletedSequenceIsAccepted() + { + var protocol = new BakeProgressProtocol(); + + Assert.True(protocol.Observe(new BakeHumanOutputEvent("human"))); + Assert.True(protocol.Observe(new UnknownBakeProgressEvent( + 1, + "newMetric", + "{}"))); + Assert.True(protocol.Observe(new FutureBakeProgressEvent( + 2, + "started", + "{}"))); + Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, "pak"))); + Assert.True(protocol.Observe(new BakeWorkProgressEvent( + 1, + "mesh", + 1, + 2, + 0, + 1, + 1))); + Assert.True(protocol.Observe(new BakeCompletedEvent(1, 4, 100, 0))); + + protocol.CompleteInput(); + + Assert.Null(protocol.Violation); + Assert.NotNull(protocol.Started); + Assert.NotNull(protocol.Completed); + Assert.Null(protocol.Error); + } + + [Theory] + [MemberData(nameof(InvalidKnownSequences))] + public void OutOfOrderDuplicateAndPostTerminalKnownEventsAreRejected( + BakeProgressEvent[] events) + { + var protocol = new BakeProgressProtocol(); + + foreach (BakeProgressEvent progressEvent in events) + { + protocol.Observe(progressEvent); + } + + protocol.CompleteInput(); + + Assert.NotNull(protocol.Violation); + } + + [Fact] + public void ErrorTerminalCannotBeOverwrittenByContradictoryCompletion() + { + var protocol = new BakeProgressProtocol(); + var failure = new BakeErrorEvent(1, "first failure"); + + Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, null))); + Assert.True(protocol.Observe(failure)); + Assert.False(protocol.Observe(new BakeCompletedEvent(1, 4, 10, 0))); + protocol.CompleteInput(); + + Assert.Same(failure, protocol.Error); + Assert.Null(protocol.Completed); + Assert.Contains("after", protocol.Violation, StringComparison.OrdinalIgnoreCase); + } + + public static TheoryData InvalidKnownSequences => new() + { + new BakeProgressEvent[] + { + new BakeWorkProgressEvent(1, "mesh", 0, 1, 0, 0, 0), + }, + new BakeProgressEvent[] + { + new BakeCompletedEvent(1, 4, 10, 0), + }, + new BakeProgressEvent[] + { + new BakeErrorEvent(1, "before start"), + }, + new BakeProgressEvent[] + { + new BakeStartedEvent(1, 4, null), + new BakeStartedEvent(1, 4, null), + }, + new BakeProgressEvent[] + { + new BakeStartedEvent(1, 4, null), + new BakeCompletedEvent(1, 4, 10, 0), + new BakeCompletedEvent(1, 4, 10, 0), + }, + new BakeProgressEvent[] + { + new BakeStartedEvent(1, 4, null), + new BakeCompletedEvent(1, 4, 10, 0), + new BakeWorkProgressEvent(1, "mesh", 1, 1, 0, 1, 0), + }, + new BakeProgressEvent[] + { + new BakeStartedEvent(1, 4, null), + }, + }; +} diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs index cefbdfaa..793dd519 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallRecordStoreTests.cs @@ -6,6 +6,13 @@ using AcDream.Platform; namespace AcDream.Launcher.Core.Tests.Installation; +[CollectionDefinition(WorkingDirectoryCollection.Name, DisableParallelization = true)] +public sealed class WorkingDirectoryCollection +{ + public const string Name = "Launcher install-record working directory"; +} + +[Collection(WorkingDirectoryCollection.Name)] public sealed class LauncherInstallRecordStoreTests : IDisposable { private readonly string _root = Path.Combine( @@ -134,6 +141,130 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task MissingExplicitVersionIsRejectedBeforeAdmission() + { + var store = new LauncherInstallRecordStore(_paths); + Directory.CreateDirectory(_paths.DataDirectory); + await File.WriteAllTextAsync( + store.RecordPath, + JsonSerializer.Serialize(new + { + datDirectory = Path.GetFullPath(_dats), + preparedAssetPath = Path.GetFullPath(store.PreparedAssetPath), + preparedAssetSha256 = new string('a', 64), + preparedAssetSize = 12, + bakeToolVersion = + LauncherInstallRecordStore.CurrentBakeToolVersion, + })); + + InstallRecordVerification verification = await store.LoadAndVerifyAsync(); + + Assert.Equal(InstallRecordVerificationState.Invalid, verification.State); + Assert.Contains("explicit", verification.Status, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SaveNormalizesCanonicalAbsoluteDatAndPreparedPaths() + { + var store = new LauncherInstallRecordStore(_paths); + Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!); + await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package"); + var info = new FileInfo(store.PreparedAssetPath); + var nonCanonical = new LauncherInstallRecord( + Path.Combine(_dats, "..", Path.GetFileName(_dats), "."), + Path.Combine( + Path.GetDirectoryName(store.PreparedAssetPath)!, + "..", + "pak", + Path.GetFileName(store.PreparedAssetPath)), + await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath), + info.Length, + LauncherInstallRecordStore.CurrentBakeToolVersion); + + await store.SaveAtomicallyAsync(nonCanonical); + + using JsonDocument document = JsonDocument.Parse( + await File.ReadAllTextAsync(store.RecordPath)); + Assert.Equal( + Path.GetFullPath(_dats), + document.RootElement.GetProperty("datDirectory").GetString()); + Assert.Equal( + Path.GetFullPath(store.PreparedAssetPath), + document.RootElement.GetProperty("preparedAssetPath").GetString()); + Assert.Equal( + LauncherInstallRecord.CurrentRecordVersion, + document.RootElement.GetProperty("version").GetInt32()); + Assert.True((await store.LoadAndVerifyAsync()).IsVerified); + } + + [Fact] + public async Task RelativeDatRecordCannotChangeMeaningWithWorkingDirectory() + { + var store = new LauncherInstallRecordStore(_paths); + Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!); + await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package"); + string alternateWorkingDirectory = Path.Combine(_root, "alternate-cwd"); + string alternateDats = Path.Combine(alternateWorkingDirectory, "retail-dats"); + CreateCompleteDatDirectory(alternateDats); + var info = new FileInfo(store.PreparedAssetPath); + var relative = new LauncherInstallRecord( + "retail-dats", + Path.GetFullPath(store.PreparedAssetPath), + await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath), + info.Length, + LauncherInstallRecordStore.CurrentBakeToolVersion); + Directory.CreateDirectory(_paths.DataDirectory); + await File.WriteAllTextAsync( + store.RecordPath, + JsonSerializer.Serialize(relative, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + })); + + string originalWorkingDirectory = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = alternateWorkingDirectory; + InstallRecordVerification verification = + await store.LoadAndVerifyAsync(); + + Assert.Equal(InstallRecordVerificationState.Invalid, verification.State); + Assert.Contains("absolute", verification.Status, StringComparison.OrdinalIgnoreCase); + } + finally + { + Environment.CurrentDirectory = originalWorkingDirectory; + } + } + + [Fact] + public async Task OlderAbsoluteButNonCanonicalDatDocumentIsRejected() + { + var store = new LauncherInstallRecordStore(_paths); + Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!); + await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package"); + var info = new FileInfo(store.PreparedAssetPath); + var nonCanonical = new LauncherInstallRecord( + Path.Combine(_dats, "..", Path.GetFileName(_dats)), + Path.GetFullPath(store.PreparedAssetPath), + await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath), + info.Length, + LauncherInstallRecordStore.CurrentBakeToolVersion); + Directory.CreateDirectory(_paths.DataDirectory); + await File.WriteAllTextAsync( + store.RecordPath, + JsonSerializer.Serialize(nonCanonical, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + })); + + InstallRecordVerification verification = await store.LoadAndVerifyAsync(); + + Assert.Equal(InstallRecordVerificationState.Invalid, verification.State); + Assert.Contains("canonical", verification.Status, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement() { diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs index 066af070..0ee05773 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json.Nodes; using AcDream.Launcher.Core.Integrity; using AcDream.Launcher.Core.Installation; @@ -168,6 +169,33 @@ public sealed class LauncherInstallerTests : IDisposable Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase); } + [Fact] + public async Task ContradictoryTerminalCannotReplaceFirstFailureOrPriorInstall() + { + (LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) = + await CreateInstallerWithPriorRecordAsync( + async (request, output, _) => + { + await File.WriteAllTextAsync(request.OutputPath, "contradictory output"); + long bytes = new FileInfo(request.OutputPath).Length; + output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n"); + output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n"); + output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4," + + $"\"outputBytes\":{bytes},\"failures\":0}}\n"); + return new BakeProcessResult(0, string.Empty); + }); + + LauncherInstallException exception = + await Assert.ThrowsAsync( + () => installer.InstallAsync(_dats, 2)); + + Assert.Contains("after", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal( + "previous verified package", + await File.ReadAllTextAsync(store.PreparedAssetPath)); + Assert.Equal(old, (await store.LoadAndVerifyAsync()).Record); + } + [Fact] public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput() { @@ -270,6 +298,187 @@ public sealed class LauncherInstallerTests : IDisposable Assert.False(File.Exists(store.RecordPath)); } + [Fact] + public async Task IndependentInstallersSerializeAndWaitingCancellationTouchesNothing() + { + var storeA = new LauncherInstallRecordStore(_paths); + LauncherInstallRecord old = await CreatePriorRecordAsync(storeA); + string backupPath = LauncherInstallRecordStore.GetBackupPath( + storeA.PreparedAssetPath); + var childEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseChild = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var runnerA = new FakeBakeProcessRunner(async (request, _, _) => + { + await File.WriteAllTextAsync(request.OutputPath, "installer A in progress"); + childEntered.SetResult(); + await releaseChild.Task; + return new BakeProcessResult(1, "fixture A failed"); + }); + bool runnerBEntered = false; + var runnerB = new FakeBakeProcessRunner((_, _, _) => + { + runnerBEntered = true; + return Task.FromResult(new BakeProcessResult(1, "must not run")); + }); + var installerA = new LauncherInstaller( + _paths, + _bakeExecutable, + recordStore: storeA, + processRunner: runnerA); + var installerB = new LauncherInstaller( + _paths, + _bakeExecutable, + recordStore: new LauncherInstallRecordStore(_paths), + processRunner: runnerB); + + Task operationA = + installerA.InstallAsync(_dats, 1); + await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + try + { + using var cancellationB = new CancellationTokenSource(); + Task operationB = installerB.InstallAsync( + _dats, + 1, + cancellationToken: cancellationB.Token); + await Task.Delay(150); + cancellationB.Cancel(); + + await Assert.ThrowsAnyAsync(() => operationB); + Assert.False(runnerBEntered); + Assert.Equal( + "installer A in progress", + await File.ReadAllTextAsync(storeA.PreparedAssetPath)); + Assert.Equal( + "previous verified package", + await File.ReadAllTextAsync(backupPath)); + } + finally + { + releaseChild.TrySetResult(); + } + + await Assert.ThrowsAsync(() => operationA); + Assert.Equal( + "previous verified package", + await File.ReadAllTextAsync(storeA.PreparedAssetPath)); + Assert.False(File.Exists(backupPath)); + Assert.Equal(old, (await storeA.LoadAndVerifyAsync()).Record); + } + + [Fact] + public void StagingCleanupDeletesOnlyExactBakeTransactionNames() + { + var store = new LauncherInstallRecordStore(_paths); + string outputPath = store.PreparedAssetPath; + string directory = Path.GetDirectoryName(outputPath)!; + Directory.CreateDirectory(directory); + string owned = BakeOutputStagingContract.CreateStagingPath( + outputPath, + Guid.Parse("01234567-89ab-cdef-0123-456789abcdef")); + string canonical = outputPath; + string backup = LauncherInstallRecordStore.GetBackupPath(outputPath); + string oldPattern = Path.Combine( + directory, + $".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp"); + string invalidTransaction = Path.Combine( + directory, + $".{Path.GetFileName(outputPath)}.acdream-bake.not-a-guid.tmp"); + string unrelated = Path.Combine(directory, "unrelated.tmp"); + Assert.Equal( + Path.Combine( + directory, + ".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"), + owned); + foreach (string path in new[] + { + owned, + canonical, + backup, + oldPattern, + invalidTransaction, + unrelated, + }) + { + File.WriteAllText(path, Path.GetFileName(path)); + } + + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + + Assert.False(File.Exists(owned)); + Assert.True(File.Exists(canonical)); + Assert.True(File.Exists(backup)); + Assert.True(File.Exists(oldPattern)); + Assert.True(File.Exists(invalidTransaction)); + Assert.True(File.Exists(unrelated)); + } + + [Fact] + public async Task KilledProcessReleasesLeaseAndRestartReclaimsOnlyBakeStaging() + { + var store = new LauncherInstallRecordStore(_paths); + LauncherInstallRecord old = await CreatePriorRecordAsync(store); + string staging = BakeOutputStagingContract.CreateStagingPath( + store.PreparedAssetPath, + Guid.Parse("fedcba98-7654-3210-fedc-ba9876543210")); + string ready = Path.Combine(_root, "fixture-ready"); + string fixtureDll = GetInstallLeaseFixturePath(); + Assert.True(File.Exists(fixtureDll), $"Missing fixture: {fixtureDll}"); + + var startInfo = new ProcessStartInfo("dotnet") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(fixtureDll); + startInfo.ArgumentList.Add( + InstallerTransactionLease.GetLockPath(store.DataDirectory)); + startInfo.ArgumentList.Add(staging); + startInfo.ArgumentList.Add(ready); + using Process helper = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start lease fixture."); + try + { + await WaitForFileAsync(ready, helper, TimeSpan.FromSeconds(10)); + Assert.True(File.Exists(staging)); + + var blockedInstaller = new LauncherInstaller( + _paths, + _bakeExecutable, + recordStore: new LauncherInstallRecordStore(_paths)); + using var blockedCancellation = new CancellationTokenSource( + TimeSpan.FromMilliseconds(200)); + await Assert.ThrowsAnyAsync( + () => blockedInstaller.LoadExistingAsync(blockedCancellation.Token)); + Assert.True(File.Exists(staging)); + } + finally + { + if (!helper.HasExited) + { + helper.Kill(entireProcessTree: true); + } + + await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + + var restarted = new LauncherInstaller( + _paths, + _bakeExecutable, + recordStore: new LauncherInstallRecordStore(_paths)); + InstallRecordVerification recovered = await restarted.LoadExistingAsync(); + + Assert.True(recovered.IsVerified); + Assert.Equal(old, recovered.Record); + Assert.False(File.Exists(staging)); + Assert.Equal( + "previous verified package", + await File.ReadAllTextAsync(store.PreparedAssetPath)); + } + private async Task<( LauncherInstaller Installer, LauncherInstallRecordStore Store, @@ -303,6 +512,80 @@ public sealed class LauncherInstallerTests : IDisposable return (installer, store, old); } + private async Task CreatePriorRecordAsync( + LauncherInstallRecordStore store) + { + Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!); + await File.WriteAllTextAsync( + store.PreparedAssetPath, + "previous verified package"); + var old = new LauncherInstallRecord( + Path.GetFullPath(_dats), + Path.GetFullPath(store.PreparedAssetPath), + await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath), + new FileInfo(store.PreparedAssetPath).Length, + LauncherInstallRecordStore.CurrentBakeToolVersion); + await store.SaveAtomicallyAsync(old); + return old; + } + + private static async Task WaitForFileAsync( + string path, + Process process, + TimeSpan timeout) + { + using var cancellation = new CancellationTokenSource(timeout); + while (!File.Exists(path)) + { + if (process.HasExited) + { + throw new InvalidOperationException( + $"Lease fixture exited with {process.ExitCode}: " + + await process.StandardError.ReadToEndAsync()); + } + + await Task.Delay(25, cancellation.Token); + } + } + + private static string GetInstallLeaseFixturePath() + { + string root = FindRepositoryRoot(); + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name + ?? "Release"; + return Path.Combine( + root, + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder", + "bin", + configuration, + "net10.0", + "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll"); + } + + private static string FindRepositoryRoot() + { + foreach (string start in new[] + { + AppContext.BaseDirectory, + Environment.CurrentDirectory, + }) + { + for (var directory = new DirectoryInfo(start); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return directory.FullName; + } + } + } + + throw new DirectoryNotFoundException("Could not locate repository root."); + } + private static void CreateCompleteDatDirectory(string directory) { Directory.CreateDirectory(directory); diff --git a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs index 74466630..5a5379a9 100644 --- a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs @@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile")); } + [Fact] + public void RidPublishComposesBakeWithoutAProjectReference() + { + string project = File.ReadAllText(Path.Combine( + FindRepositoryRoot(), + "src", + "AcDream.Launcher", + "AcDream.Launcher.csproj")); + + Assert.DoesNotContain( + "ProjectReference Include=\"..\\AcDream.Bake", + project, + StringComparison.Ordinal); + Assert.Contains("PublishCoDeployedBakeTool", project, StringComparison.Ordinal); + Assert.Contains("..\\AcDream.Bake\\AcDream.Bake.csproj", project, StringComparison.Ordinal); + Assert.Contains("SelfContained=true", project, StringComparison.Ordinal); + Assert.Contains("PublishSingleFile=true", project, StringComparison.Ordinal); + } + [Fact] public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards() { @@ -119,6 +138,9 @@ public sealed class LauncherProjectBoundaryTests Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal); Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal); Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal); + Assert.Contains("acdream-bake.exe\" --help", workflow, StringComparison.Ordinal); + Assert.Contains("\"$root/acdream-bake\" --help", workflow, StringComparison.Ordinal); + Assert.Contains("test -x \"$root/acdream-bake\"", workflow, StringComparison.Ordinal); Assert.Contains( "test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless", workflow, From 259f0e5ac3ce3e3d27db3d72036a1bc0584be010 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 20:47:06 +0200 Subject: [PATCH 043/138] fix(headless): route wire-only chat commands --- .../Hosting/HeadlessSessionHost.cs | 44 ++++++ .../Net/LiveSessionCommandRouterTests.cs | 47 +++++++ .../HeadlessSessionHostTests.cs | 128 ++++++++++++++++++ 3 files changed, 219 insertions(+) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 5219f94c..21b168f3 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -929,6 +929,24 @@ internal sealed class HeadlessSessionHost : IDisposable case ClientCommandId.ClearChat: runtime.CommunicationOwner.Chat.Clear(); return; + case ClientCommandId.ChatToggle: + // Retail DoChatToggle: "off" adds the global Speech + // squelch; "on" removes it. + session.SendModifyGlobalSquelch( + command.Arguments.Equals( + "off", + StringComparison.OrdinalIgnoreCase), + 2u); + return; + case ClientCommandId.NoTellToggle: + // Retail DoNoTell: "on" adds the global Tell squelch; + // "off" removes it. + session.SendModifyGlobalSquelch( + command.Arguments.Equals( + "on", + StringComparison.OrdinalIgnoreCase), + 3u); + return; case ClientCommandId.IndexChannels: session.SendIndexChannels(); return; @@ -953,6 +971,9 @@ internal sealed class HeadlessSessionHost : IDisposable case ClientCommandId.AllegianceInfo: session.SendAllegianceInfoRequest(command.Arguments.Trim()); return; + case ClientCommandId.Permit: + ExecutePermit(session, command.Arguments); + return; case ClientCommandId.HouseAvailableList when RetailClientCommandCatalog.TryResolveHouseType( command.Arguments, @@ -996,6 +1017,29 @@ internal sealed class HeadlessSessionHost : IDisposable } send(channelId); } + + static void ExecutePermit( + AcDream.Core.Net.WorldSession activeSession, + string arguments) + { + // The catalog has already required add/remove plus a name. + // Match ClientCommandController's JoinArgsAsName behavior so + // multi-word character names remain one exact wire argument. + string[] parts = arguments.Split( + (char[]?)null, + StringSplitOptions.RemoveEmptyEntries); + string name = string.Join(' ', parts, 1, parts.Length - 1); + if (parts[0].Equals( + "add", + StringComparison.OrdinalIgnoreCase)) + { + activeSession.SendAddPlayerPermission(name); + } + else + { + activeSession.SendRemovePlayerPermission(name); + } + } } private ILiveSessionEventRouting CreateEventRoute( diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 2812f7bf..054c6e96 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -54,6 +54,53 @@ public sealed class LiveSessionCommandRouterTests calls); } + [Fact] + public void GraphicalRouteKeepsWireOnlyClientCommandParityWithHeadless() + { + var calls = new List(); + ClientCommandController.Bindings client = NewClientBindings() with + { + AddPlayerPermission = name => + calls.Add($"permit:add:{name}"), + RemovePlayerPermission = name => + calls.Add($"permit:remove:{name}"), + ModifyGlobalSquelch = (add, messageType) => + calls.Add($"squelch:{add}:{messageType}"), + }; + var router = NewRouter(clientBindings: client); + router.Activate(); + + router.Publish(new ExecuteClientCommandCmd( + ClientCommandId.Permit, + "add Aunt Agatha")); + router.Publish(new ExecuteClientCommandCmd( + ClientCommandId.Permit, + "remove Lord Gnarly Beard")); + router.Publish(new ExecuteClientCommandCmd( + ClientCommandId.ChatToggle, + "on")); + router.Publish(new ExecuteClientCommandCmd( + ClientCommandId.ChatToggle, + "off")); + router.Publish(new ExecuteClientCommandCmd( + ClientCommandId.NoTellToggle, + "on")); + router.Publish(new ExecuteClientCommandCmd( + ClientCommandId.NoTellToggle, + "off")); + + Assert.Equal( + [ + "permit:add:Aunt Agatha", + "permit:remove:Lord Gnarly Beard", + "squelch:False:2", + "squelch:True:2", + "squelch:True:3", + "squelch:False:3", + ], + calls); + } + [Fact] public void InactiveAndDisposedRouter_CannotReachTransport() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 8dabeb98..7a7081ac 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -71,6 +71,127 @@ public sealed class HeadlessSessionHostTests BinaryPrimitives.ReadUInt32LittleEndian(captured[3].AsSpan(12))); } + [Fact] + public void LoginCommandsRouteWireOnlyClientCommandsWithExactPolarityAndOrder() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor( + loginCommands: + [ + "/permit add Aunt Agatha", + "@permit remove Lord Gnarly Beard", + "/chat on", + "/chat off", + "/notell on", + "/notell off", + ], + loginCommandDelayMs: 0), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + Assert.Equal(RuntimeSessionStartStatus.Connected, host.Start().Status); + Assert.Equal( + [ + ClientCommandRequests.AddPlayerPermissionOpcode, + ClientCommandRequests.RemovePlayerPermissionOpcode, + ClientCommandRequests.ModifyGlobalSquelchOpcode, + ClientCommandRequests.ModifyGlobalSquelchOpcode, + ClientCommandRequests.ModifyGlobalSquelchOpcode, + ClientCommandRequests.ModifyGlobalSquelchOpcode, + ], + captured.Select(ActionOpcode)); + Assert.Equal("Aunt Agatha", StringActionArgument(captured[0])); + Assert.Equal("Lord Gnarly Beard", StringActionArgument(captured[1])); + Assert.Equal( + [ + (Add: 0u, MessageType: 2u), + (Add: 1u, MessageType: 2u), + (Add: 1u, MessageType: 3u), + (Add: 0u, MessageType: 3u), + ], + captured.Skip(2).Select(static body => ( + Add: BinaryPrimitives.ReadUInt32LittleEndian( + body.AsSpan(12, sizeof(uint))), + MessageType: BinaryPrimitives.ReadUInt32LittleEndian( + body.AsSpan(16, sizeof(uint)))))); + } + + [Theory] + [InlineData("/permit add")] + [InlineData("/chat maybe")] + [InlineData("/notell maybe")] + public void InvalidWireOnlyArgumentKeepsTypedFeedbackWithoutStatusFailure( + string invalidCommand) + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-wire-client-errors-{Guid.NewGuid():N}.jsonl"); + try + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor( + statusFile: statusPath, + loginCommands: + [ + invalidCommand, + "after", + ], + loginCommandDelayMs: 0), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + Assert.Single(captured); + Assert.Equal("after", TalkText(captured[0])); + + // Invalid registered-command arguments are handled exactly as + // typed: retail's ClientLocal refusal reaches canonical Runtime + // feedback and is not misclassified as a transport failure. + host.Runtime.CommunicationOwner.SpewBox.Tick(0d); + Assert.Equal( + "That is not a valid command.", + Assert.Single( + host.Runtime.CommunicationOwner.SpewBox.Snapshot()).Text); + + JsonElement[] events = File.ReadAllLines(statusPath) + .Select(static line => + JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + Assert.DoesNotContain( + events, + static item => item.GetProperty("e").GetString() + == "loginCommandFailed"); + Assert.True(host.Runtime.Session.IsInWorld); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + [Fact] public void LoginCommandFailuresAreVersionedOrderedAndSessionIsolated() { @@ -3286,6 +3407,13 @@ public sealed class HeadlessSessionHostTests return System.Text.Encoding.ASCII.GetString(body, 14, length); } + private static string StringActionArgument(byte[] body) + { + ushort length = BinaryPrimitives.ReadUInt16LittleEndian( + body.AsSpan(12, sizeof(ushort))); + return System.Text.Encoding.ASCII.GetString(body, 14, length); + } + private sealed class ManualTimeProvider : TimeProvider { private long _timestamp; From aeac874dab136eceecaa69e85f98534885724f8b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 20:59:10 +0200 Subject: [PATCH 044/138] Harden retail character selection recovery --- .../2026-08-14-la8-character-management-ui.md | 23 +- .../Layout/CharacterManagementUiController.cs | 90 +++++++- .../CharacterManagementUiMountCoordinator.cs | 87 ++++++++ .../UI/Layout/RetailDialogFactory.cs | 196 ++++++++++++++--- src/AcDream.App/UI/RetailUiRuntime.cs | 77 ++++--- .../Layout/CharacterManagementLiveDatTests.cs | 25 ++- .../CharacterManagementUiControllerTests.cs | 208 ++++++++++++++++++ .../UI/Layout/RetailDialogFactoryTests.cs | 111 ++++++++++ 8 files changed, 736 insertions(+), 81 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs diff --git a/docs/research/2026-08-14-la8-character-management-ui.md b/docs/research/2026-08-14-la8-character-management-ui.md index ac64abb8..1bde915c 100644 --- a/docs/research/2026-08-14-la8-character-management-ui.md +++ b/docs/research/2026-08-14-la8-character-management-ui.md @@ -23,7 +23,10 @@ The implementation was derived from those four button pointers, the selected row/guid, and four dialog contexts. It declares no viewport, `gmCG3DView`, or preview owner. - `RebuildCharacterList` (`0x004EC3A0`) creates each row through - `AddItemFromTemplateList`, retains character identity, displays pending + `AddItemFromTemplateList`, then resizes it using signed integer division: + `max(listHeight / max(rosterCount, allowedSlots), listHeight / 10)`. Thus a + 320-pixel list with five allowed slots uses 64-pixel rows, while rosters over + ten clamp at 32 pixels. It retains character identity, displays pending deletion in red, sorts by ordinal name, moves greyed entries to the tail, and restores/falls back selection. LA8 preserves the already canonical LA7b display order and identity instead of sorting an App copy. @@ -55,6 +58,8 @@ with `ACDREAM_PROBE_LIVE_MOUNT=1`; it reads the ordinary `%USERPROFILE%/Documents/Asheron's Call` DAT set unless `ACDREAM_DAT_DIR` overrides the location. It uses production `DatCollection`, `RetailDataIdResolver`, and `LayoutImporter`; it does not write the DATs. +When the opt-in flag or installed data is absent, discovery records an explicit +skip rather than adding a no-op pass to default suite totals. The installed September-2013 data proves: @@ -89,11 +94,17 @@ The controller instantiates the authored row template in Runtime display order, projects red pending-delete rows and the exact button matrix, and opens the shared retail dialogs. Delete wait survives the opcode-only acknowledgement until the fresh roster arrives. Restore is fire-and-observe: a silent ACE -no-reply ends only when Runtime expires its correlation. Entering-world wait -opens before the existing synchronous Enter command; error, reset, reconnect, -missing/displaced adapter, and disposal close owned contexts without re-entrant -commands. A failed transient row-template import leaves the Runtime revision -unconsumed and retries on the next frame. +no-reply ends only when Runtime expires its correlation; retail's Please Wait +opens before the synchronous restore command and closes immediately if that +command rejects or throws. Entering-world wait opens before the existing +synchronous Enter command; error, reset, reconnect, missing/displaced adapter, +and disposal close owned contexts without re-entrant commands. A failed +transient row-template import leaves the Runtime revision unconsumed and +retries on the next frame. Initial dialog-catalog, character root, and string +misses likewise retry on later ticks without mounting a duplicate root or +controller. Dialog presenter/catalog failures move their contexts to an +internal retry ledger, so UI callbacks do not retain poisoned active/queued +entries and the same context can appear after resource recovery. There is deliberately no 3D preview and no claimed character-select background scene. The screen root remains neutral with respect to render-loop background diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index 5a2759a0..02f27ce5 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -45,6 +45,7 @@ internal sealed class CharacterManagementUiController : IDisposable private uint _enterWaitContext; private uint _errorDialogContext; private bool _active; + private bool _restoreCommandInFlight; private bool _suppressDialogCallbacks; private bool _disposed; @@ -197,7 +198,7 @@ internal sealed class CharacterManagementUiController : IDisposable if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster)) { bool rowsReady; - if (RowsMatchRoster(roster)) + if (RowsMatchRoster(roster, snapshot.SlotCount)) { ApplyHighlight(snapshot.HighlightedCharacterId); rowsReady = true; @@ -206,6 +207,7 @@ internal sealed class CharacterManagementUiController : IDisposable { rowsReady = RebuildRows( roster, + snapshot.SlotCount, snapshot.HighlightedCharacterId); } @@ -280,11 +282,16 @@ internal sealed class CharacterManagementUiController : IDisposable } private bool RowsMatchRoster( - IReadOnlyList roster) + IReadOnlyList roster, + int allowedSlotCount) { if (_rows.Count != roster.Count) return false; + int rowHeight = ComputeRowHeight( + _list.Height, + roster.Count, + allowedSlotCount); for (int i = 0; i < roster.Count; i++) { UiButton row = _rows[i]; @@ -292,6 +299,7 @@ internal sealed class CharacterManagementUiController : IDisposable if (!_rowIds.TryGetValue(row, out uint characterId) || characterId != character.CharacterId || !string.Equals(row.Label, character.Name, StringComparison.Ordinal) + || (int)row.Height != rowHeight || row.LabelColor != (character.IsPendingDelete ? new Vector4(1f, 0f, 0f, 1f) : Vector4.One)) @@ -305,6 +313,7 @@ internal sealed class CharacterManagementUiController : IDisposable private bool RebuildRows( IReadOnlyList roster, + int allowedSlotCount, uint highlightedCharacterId) { foreach (UiButton row in _rows) @@ -316,15 +325,34 @@ internal sealed class CharacterManagementUiController : IDisposable _rowIds.Clear(); _list.Flush(); - bool complete = true; + int rowHeight = ComputeRowHeight( + _list.Height, + roster.Count, + allowedSlotCount); + _list.LineHeight = rowHeight; + bool complete = _list.Templates.Count > 0 + && _list.TemplateResolver is not null; foreach (RuntimeCharacterSelectionEntry character in roster) { - if (_list.AddItemFromTemplateList(0) is not UiButton row) + if (!complete) + break; + + UiTemplateListEntry template = _list.Templates[0]; + if (_list.TemplateResolver!( + template.TemplateLayoutId, + template.TemplateElementId) is not UiButton row) { complete = false; break; } + // AddItemFromTemplateList creates the same template, but its + // retained viewport stacks at the template's authored 16px + // height. Retail establishes the computed size on every row; our + // list fixes Top during insertion, so build and resize first to + // make every subsequent Top exact. + row.Height = rowHeight; + _list.AddPrebuiltRow(row); uint characterId = character.CharacterId; row.Label = character.Name; row.LabelColor = character.IsPendingDelete @@ -354,6 +382,21 @@ internal sealed class CharacterManagementUiController : IDisposable return false; } + internal static int ComputeRowHeight( + float listHeight, + int rosterCount, + int allowedSlotCount) + { + // RebuildCharacterList @ 0x004EC3A0 uses integer UIRegion height and + // signed integer division for both terms. The 0x66666667 multiply/ + // shift sequence is compiler output for height / 10. + int height = (int)MathF.Truncate(listHeight); + int denominator = Math.Max(rosterCount, allowedSlotCount); + if (denominator <= 0) + return height / 10; + return Math.Max(height / denominator, height / 10); + } + private void ApplyHighlight(uint highlightedCharacterId) { foreach (UiButton row in _rows) @@ -407,9 +450,39 @@ internal sealed class CharacterManagementUiController : IDisposable { if (_disposed) return; - RuntimeCommandResult result = _bindings.Restore(); - if (result.Accepted) - EnsureOperationWait(); + + // ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it + // calls CPlayerSystem::RestoreCharacter. Keep it modal even if a + // synchronous command callback re-enters Tick before Runtime has + // returned its accepted projection. + EnsureOperationWait(); + RuntimeCommandResult result = default; + Exception? failure = null; + _restoreCommandInFlight = true; + try + { + result = _bindings.Restore(); + } + catch (Exception error) + { + failure = error; + } + finally + { + _restoreCommandInFlight = false; + } + + if (failure is not null) + { + Console.WriteLine( + $"[UI] character restore command failed: {failure.Message}"); + CloseContext(ref _operationWaitContext, suppressCallback: true); + InvalidateAndTick(); + return; + } + + if (!result.Accepted) + CloseContext(ref _operationWaitContext, suppressCallback: true); InvalidateAndTick(); } @@ -446,7 +519,8 @@ internal sealed class CharacterManagementUiController : IDisposable CloseContext(ref _deleteDialogContext, suppressCallback: true); } - if (snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested + if (_restoreCommandInFlight + || snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested or RuntimeCharacterSelectionOperation.DeleteAcknowledged or RuntimeCharacterSelectionOperation.RestoreRequested) { diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs new file mode 100644 index 00000000..e297d5ac --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs @@ -0,0 +1,87 @@ +namespace AcDream.App.UI.Layout; + +internal sealed record CharacterManagementUiMountResources( + uint LayoutId, + ImportedLayout Layout, + Func TemplateResolver, + CharacterManagementUiController.DialogStrings Strings); + +/// +/// Retryable, idempotent composition edge for the pre-world character screen. +/// DATs can become readable after the graphical runtime starts (installer copy, +/// mapped-file replacement, or a transient catalog miss), so an unavailable +/// dialog catalog, root, template, or string must not permanently suppress the +/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or +/// controller lifetime. +/// +internal sealed class CharacterManagementUiMountCoordinator : IDisposable +{ + private readonly UiRoot _host; + private readonly CharacterSelectionRuntimeBindings _bindings; + private readonly Func _ensureDialogs; + private readonly Func _loadResources; + private bool _disposed; + + public CharacterManagementUiMountCoordinator( + UiRoot host, + CharacterSelectionRuntimeBindings bindings, + Func ensureDialogs, + Func loadResources) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + _ensureDialogs = ensureDialogs + ?? throw new ArgumentNullException(nameof(ensureDialogs)); + _loadResources = loadResources + ?? throw new ArgumentNullException(nameof(loadResources)); + } + + public CharacterManagementUiController? Controller { get; private set; } + + public void Tick() + { + if (_disposed || Controller is not null) + return; + + try + { + RetailDialogFactory? dialogs = _ensureDialogs(); + if (dialogs is null) + return; + + CharacterManagementUiMountResources? resources = _loadResources(); + if (resources is null) + return; + + Controller = CharacterManagementUiController.Bind( + _host, + resources.Layout, + resources.TemplateResolver, + dialogs, + _bindings, + resources.Strings); + if (Controller is not null) + { + Console.WriteLine( + $"[UI] retail character management from enum table 5 " + + $"(0x10000005 -> 0x{resources.LayoutId:X8}, " + + "root 0x1000039A; flat list, no viewport)."); + } + } + catch (Exception error) + { + Console.WriteLine( + "[UI] character management mount will retry after resource " + + $"recovery: {error.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Controller?.Dispose(); + Controller = null; + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 33a3bf3a..f631adb1 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -24,6 +24,7 @@ public sealed class RetailDialogFactory : IDisposable private readonly Dictionary _activeQueued = new(); private readonly Dictionary _activeNonQueued = new(); private readonly Dictionary> _pending = new(); + private readonly LinkedList _retryable = new(); private readonly List _openOrder = new(); private uint _globalContext; private bool _resetting; @@ -51,6 +52,8 @@ public sealed class RetailDialogFactory : IDisposable public int PendingCount => _pending.Values.Sum(static queue => queue.Count); + internal int RetryCount => _retryable.Count; + /// Exact root-element switch from CreateDialog_ @ 0x00477AD0. public static uint RootElementId(RetailDialogType type) => type switch @@ -93,14 +96,28 @@ public sealed class RetailDialogFactory : IDisposable if (queueKey == NonQueuedKey) { _activeNonQueued.Add(context, info); - CreateDialog(info); + if (!TryCreateDialog(info)) + { + _activeNonQueued.Remove(context); + QueueRetry(info); + } return context; } if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current)) { + if (HasRetry(queueKey)) + { + PendingQueue(queueKey).AddLast(info); + return context; + } + _activeQueued.Add(queueKey, info); - CreateDialog(info); + if (!TryCreateDialog(info)) + { + _activeQueued.Remove(queueKey); + QueueRetry(info); + } return context; } @@ -118,7 +135,15 @@ public sealed class RetailDialogFactory : IDisposable Suspend(current); queue.AddFirst(current); _activeQueued[queueKey] = info; - CreateDialog(info); + if (!TryCreateDialog(info)) + { + _activeQueued.Remove(queueKey); + queue.Remove(current); + if (queue.Count == 0) + _pending.Remove(queueKey); + OpenSpecificDialog(current); + QueueRetry(info); + } return context; } @@ -211,11 +236,25 @@ public sealed class RetailDialogFactory : IDisposable return true; } + LinkedListNode? retry = _retryable.First; + while (retry is not null && retry.Value.Context != context) + retry = retry.Next; + if (retry is not null) + { + DialogInfo failed = retry.Value; + _retryable.Remove(retry); + DialogDone(failed); + if (failed.QueueKey != NonQueuedKey) + OpenNextDialog(failed.QueueKey); + return true; + } + return false; } public void Tick() { + RetryFailedDialogs(); foreach (DialogInfo info in _openOrder.ToArray()) info.View?.Tick(); } @@ -238,6 +277,7 @@ public sealed class RetailDialogFactory : IDisposable DialogInfo[] infos = _activeNonQueued.Values .Concat(_activeQueued.Values) .Concat(_pending.Values.SelectMany(static queue => queue)) + .Concat(_retryable) .Distinct() .ToArray(); if (infos.Length == 0) @@ -249,6 +289,7 @@ public sealed class RetailDialogFactory : IDisposable _activeNonQueued.Clear(); _activeQueued.Clear(); _pending.Clear(); + _retryable.Clear(); foreach (DialogInfo info in infos) { try { DialogDone(info); } @@ -292,41 +333,57 @@ public sealed class RetailDialogFactory : IDisposable return queue; } - private void CreateDialog(DialogInfo info) + private bool TryCreateDialog(DialogInfo info) { - RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type); - if (type is not (RetailDialogType.Confirmation - or RetailDialogType.Wait - or RetailDialogType.Message - or RetailDialogType.ConfirmationTextInput)) - throw new NotSupportedException( - $"Retail dialog type {(uint)type} does not have a ported presenter yet."); - - ImportedLayout layout = _createLayout(type) - ?? throw new InvalidOperationException( - $"Retail dialog catalog could not create type {(uint)type}."); - IRetailDialogView view = type switch + RetailDialogType type = (RetailDialogType)info.Data.GetUInt32( + RetailDialogProperty.Type); + try { - RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data), - RetailDialogType.Message => new RetailMessageDialogView( - _host, layout, info.Data, info.Context, - context => CloseDialog(context)), - RetailDialogType.ConfirmationTextInput => - new RetailConfirmationTextInputDialogView( + if (type is not (RetailDialogType.Confirmation + or RetailDialogType.Wait + or RetailDialogType.Message + or RetailDialogType.ConfirmationTextInput)) + { + throw new NotSupportedException( + $"Retail dialog type {(uint)type} does not have a ported presenter yet."); + } + + ImportedLayout layout = _createLayout(type) + ?? throw new InvalidOperationException( + $"Retail dialog catalog could not create type {(uint)type}."); + IRetailDialogView view = type switch + { + RetailDialogType.Wait => new RetailWaitDialogView( + _host, layout, info.Data), + RetailDialogType.Message => new RetailMessageDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), - _ => new RetailConfirmationDialogView( - _host, layout, info.Data, info.Context, - context => CloseDialog(context)), - }; - info.View = view; - _host.AddChild(view.Root); - _host.BringToFront(view.Root); - _openOrder.Add(info); - _host.Modal = view.Root; - view.Tick(); - UpdatePendingDialogDisplays(); - DialogOpened?.Invoke(info.Context); + RetailDialogType.ConfirmationTextInput => + new RetailConfirmationTextInputDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), + _ => new RetailConfirmationDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), + }; + info.View = view; + _host.AddChild(view.Root); + _host.BringToFront(view.Root); + _openOrder.Add(info); + _host.Modal = view.Root; + view.Tick(); + UpdatePendingDialogDisplays(); + DialogOpened?.Invoke(info.Context); + return true; + } + catch (Exception error) + { + RemoveView(info); + Console.WriteLine( + $"[UI] retail dialog type {(uint)type} context {info.Context} " + + $"will retry after catalog recovery: {error.Message}"); + return false; + } } private void Suspend(DialogInfo info) @@ -380,6 +437,9 @@ public sealed class RetailDialogFactory : IDisposable if (_activeQueued.ContainsKey(queueKey)) return; + if (TryActivateRetry(queueKey)) + return; + if (!_pending.TryGetValue(queueKey, out LinkedList? queue) || queue.First is null) return; @@ -389,7 +449,73 @@ public sealed class RetailDialogFactory : IDisposable if (queue.Count == 0) _pending.Remove(queueKey); _activeQueued.Add(queueKey, next); - CreateDialog(next); + if (!TryCreateDialog(next)) + { + _activeQueued.Remove(queueKey); + QueueRetry(next); + } + } + + private void OpenSpecificDialog(DialogInfo info) + { + _activeQueued.Add(info.QueueKey, info); + if (!TryCreateDialog(info)) + { + _activeQueued.Remove(info.QueueKey); + QueueRetry(info); + } + } + + private void RetryFailedDialogs() + { + foreach (DialogInfo info in _retryable.ToArray()) + { + if (info.QueueKey == NonQueuedKey) + { + _activeNonQueued.Add(info.Context, info); + if (TryCreateDialog(info)) + _retryable.Remove(info); + else + _activeNonQueued.Remove(info.Context); + continue; + } + + if (!_activeQueued.ContainsKey(info.QueueKey) + && ReferenceEquals(FirstRetry(info.QueueKey), info)) + { + TryActivateRetry(info.QueueKey); + } + } + } + + private bool TryActivateRetry(uint queueKey) + { + DialogInfo? info = FirstRetry(queueKey); + if (info is null) + return false; + + _activeQueued.Add(queueKey, info); + if (TryCreateDialog(info)) + _retryable.Remove(info); + else + _activeQueued.Remove(queueKey); + return true; + } + + private DialogInfo? FirstRetry(uint queueKey) + { + foreach (DialogInfo info in _retryable) + if (info.QueueKey == queueKey) + return info; + return null; + } + + private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null; + + private void QueueRetry(DialogInfo info) + { + if (!_retryable.Contains(info)) + _retryable.AddLast(info); } private void UpdatePendingDialogDisplays() diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index df70aa93..d8253c91 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -434,6 +434,7 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; + private CharacterManagementUiMountCoordinator? _characterManagementMount; private IDisposable? _characterSheetSubscription; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -500,7 +501,8 @@ public sealed class RetailUiRuntime : IDisposable MountVendor(); MountSecureTrade(); MountItemCooldowns(); - MountCharacterManagement(); + ConfigureCharacterManagement(); + _characterManagementMount?.Tick(); Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; BindToolbarPanelButtons(); SyncToolbarWindowButtons(); @@ -595,7 +597,8 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } - internal CharacterManagementUiController? CharacterManagementController { get; private set; } + internal CharacterManagementUiController? CharacterManagementController => + _characterManagementMount?.Controller; public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { @@ -641,6 +644,7 @@ public sealed class RetailUiRuntime : IDisposable ExternalContainerController?.Tick(); SocialPanelController?.Tick(); _itemCooldownController?.Tick(); + _characterManagementMount?.Tick(); CharacterManagementController?.Tick(); DialogFactory?.Tick(); Host.Tick(deltaSeconds); @@ -3051,13 +3055,29 @@ public sealed class RetailUiRuntime : IDisposable private void MountDialogFactory() { + if (DialogFactory is not null) + return; + uint layoutId; - lock (_bindings.Assets.DatLock) + try { - // DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared - // catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then - // creates a fresh type-specific root from that catalog. - layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u); + lock (_bindings.Assets.DatLock) + { + // DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared + // catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then + // creates a fresh type-specific root from that catalog. + layoutId = RetailDataIdResolver.Resolve( + _bindings.Assets.Dats, + 2u, + 5u); + } + } + catch (Exception error) + { + Console.WriteLine( + "[UI] retail dialog catalog will retry after resource " + + $"recovery: {error.Message}"); + return; } if (layoutId == 0u) @@ -3690,19 +3710,28 @@ public sealed class RetailUiRuntime : IDisposable "[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D."); } - private void MountCharacterManagement() + private void ConfigureCharacterManagement() { CharacterSelectionRuntimeBindings? bindings = _bindings.CharacterSelection; - if (bindings is null) + if (bindings is null || _characterManagementMount is not null) return; - if (DialogFactory is null) - { - Console.WriteLine( - "[UI] character management: retail DialogFactory is unavailable."); - return; - } + _characterManagementMount = new CharacterManagementUiMountCoordinator( + Host.Root, + bindings, + EnsureDialogFactory, + LoadCharacterManagementResources); + } + + private RetailDialogFactory? EnsureDialogFactory() + { + MountDialogFactory(); + return DialogFactory; + } + + private CharacterManagementUiMountResources? LoadCharacterManagementResources() + { const uint stringTableId = 0x23000002u; uint layoutId; ImportedLayout? layout; @@ -3730,7 +3759,7 @@ public sealed class RetailUiRuntime : IDisposable { Console.WriteLine( "[UI] character management: enum-table-5 root could not be imported."); - return; + return null; } string? deleteResponse; @@ -3767,7 +3796,7 @@ public sealed class RetailUiRuntime : IDisposable { Console.WriteLine( "[UI] character management: required retail strings are unavailable."); - return; + return null; } UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) @@ -3798,23 +3827,15 @@ public sealed class RetailUiRuntime : IDisposable } } - CharacterManagementController = CharacterManagementUiController.Bind( - Host.Root, + return new CharacterManagementUiMountResources( + layoutId, layout, ResolveTemplate, - DialogFactory, - bindings, new CharacterManagementUiController.DialogStrings( ComposeDeleteConfirmation, deleteResponse, pleaseWait, enteringWorld)); - - if (CharacterManagementController is null) - return; - Console.WriteLine( - $"[UI] retail character management from enum table 5 " - + $"(0x10000005 -> 0x{layoutId:X8}, root 0x1000039A; flat list, no viewport)."); } private static string? ResolveCharacterManagementString( @@ -3873,7 +3894,7 @@ public sealed class RetailUiRuntime : IDisposable () => _itemConfirmationController?.Dispose(), () => { - CharacterManagementController?.Dispose(); + _characterManagementMount?.Dispose(); _gameplayConfirmationController?.Dispose(); }, () => DialogFactory?.Dispose(), diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 15c705ae..88ceb7b0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -15,12 +15,9 @@ namespace AcDream.App.Tests.UI.Layout; /// public sealed class CharacterManagementLiveDatTests { - [Fact] + [InstalledDatFact] public void EnumTable5_ResolvesAndImportsTheExactRetailScreenAndDialogs() { - if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1") - return; - string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), @@ -162,3 +159,23 @@ public sealed class CharacterManagementLiveDatTests yield return descendant; } } + +internal sealed class InstalledDatFactAttribute : FactAttribute +{ + public InstalledDatFactAttribute() + { + if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1") + { + Skip = "Set ACDREAM_PROBE_LIVE_MOUNT=1 to run the installed-DAT LA8 gate."; + return; + } + + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + if (!File.Exists(Path.Combine(datDirectory, "client_portal.dat"))) + Skip = $"Installed client_portal.dat is required at '{datDirectory}'."; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index 93920e74..9cc8a664 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -44,6 +44,9 @@ public sealed class CharacterManagementUiControllerTests Assert.Equal( ["Alpha", "Zulu", "Aaron (pending)"], controller.Rows.Select(static row => row.Label!).ToArray()); + Assert.All(controller.Rows, static row => Assert.Equal(64f, row.Height)); + Assert.Equal([0f, 64f, 128f], + controller.Rows.Select(static row => row.Top).ToArray()); Assert.True(controller.Rows[0].Selected); Assert.False(controller.Rows[1].Selected); Assert.Equal(Vector4.One, controller.Rows[0].LabelColor); @@ -65,6 +68,126 @@ public sealed class CharacterManagementUiControllerTests Assert.True(restore.Enabled); } + [Fact] + public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + + Assert.Equal(64, CharacterManagementUiController.ComputeRowHeight( + 320f, + rosterCount: 3, + allowedSlotCount: 5)); + Assert.Equal(63, CharacterManagementUiController.ComputeRowHeight( + 319f, + rosterCount: 5, + allowedSlotCount: 5)); + Assert.Equal(31, CharacterManagementUiController.ComputeRowHeight( + 319f, + rosterCount: 11, + allowedSlotCount: 5)); + + RuntimeCharacterSelectionEntry[] large = Enumerable.Range(0, 12) + .Select(index => new RuntimeCharacterSelectionEntry( + index, + (uint)(0x50000100 + index), + $"Character {index:D2}", + 0u)) + .ToArray(); + environment.Runtime.ReplaceRoster( + large, + highlightedCharacterId: large[0].CharacterId); + controller.Tick(); + + Assert.Equal(12, controller.Rows.Count); + Assert.All(controller.Rows, static row => Assert.Equal(32f, row.Height)); + Assert.Equal( + Enumerable.Range(0, 12).Select(static index => index * 32f), + controller.Rows.Select(static row => row.Top)); + UiTemplateListBox list = Assert.IsType( + environment.Screen.FindElement( + CharacterManagementUiController.ListElementId)); + Assert.Equal(384, list.ContentHeight); + Assert.Equal(32, list.LineHeight); + } + + [Fact] + public void MountCoordinator_RetriesCatalogRootAndStrings_ThenMountsOnce() + { + var host = new UiRoot(); + var runtime = new FakeRuntime(); + using var dialogs = new RetailDialogFactory( + host, + RetailDialogFactoryTests.BuildDialogLayout); + bool catalogAvailable = false; + bool rootAvailable = false; + bool stringsAvailable = false; + int dialogAttempts = 0; + int resourceAttempts = 0; + using var coordinator = new CharacterManagementUiMountCoordinator( + host, + runtime.Bindings, + () => + { + dialogAttempts++; + return catalogAvailable ? dialogs : null; + }, + () => + { + resourceAttempts++; + if (!rootAvailable || !stringsAvailable) + return null; + return new CharacterManagementUiMountResources( + 0x21000004u, + BuildScreen(), + static (layoutId, elementId) => + layoutId == 0x21000004u + && elementId == 0x100003A5u + ? BuildRow() + : null, + TestStrings()); + }); + + coordinator.Tick(); + Assert.Null(coordinator.Controller); + Assert.Equal(1, dialogAttempts); + Assert.Equal(0, resourceAttempts); + + catalogAvailable = true; + coordinator.Tick(); + Assert.Null(coordinator.Controller); + Assert.Equal(2, dialogAttempts); + Assert.Equal(1, resourceAttempts); + + rootAvailable = true; + coordinator.Tick(); + Assert.Null(coordinator.Controller); + Assert.Equal(3, dialogAttempts); + Assert.Equal(2, resourceAttempts); + + stringsAvailable = true; + coordinator.Tick(); + CharacterManagementUiController controller = Assert.IsType< + CharacterManagementUiController>(coordinator.Controller); + Assert.Single(host.Children); + Assert.Same(controller.Root, host.Children[0]); + Assert.Equal(4, dialogAttempts); + Assert.Equal(3, resourceAttempts); + + coordinator.Tick(); + Assert.Same(controller, coordinator.Controller); + Assert.Single(host.Children); + Assert.Equal(4, dialogAttempts); + Assert.Equal(3, resourceAttempts); + + coordinator.Dispose(); + Assert.Empty(host.Children); + coordinator.Tick(); + Assert.Null(coordinator.Controller); + Assert.Equal(4, dialogAttempts); + Assert.Equal(3, resourceAttempts); + } + [Fact] public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster() { @@ -218,6 +341,78 @@ public sealed class CharacterManagementUiControllerTests Assert.False(environment.Dialogs.IsOpen); } + [Fact] + public void Restore_OpensWaitBeforeCommand_AndKeepsOneModalAcrossReentrantTick() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + controller.Rows[2].OnClick!(); + uint observedContext = 0u; + environment.Runtime.BeforeRestore = () => + { + observedContext = controller.OperationWaitContext; + Assert.NotEqual(0u, observedContext); + Assert.True(environment.Dialogs.IsOpen); + Assert.Same( + environment.LastDialog(RetailDialogType.Wait).Root, + environment.Host.Modal); + + // A synchronous callback can pump the presentation before the + // command has returned. The in-flight edge must retain the one + // wait context instead of closing/reopening it. + controller.Tick(); + Assert.Equal(observedContext, controller.OperationWaitContext); + }; + environment.Runtime.AfterRestoreProjection = () => + { + controller.Tick(); + Assert.Equal(observedContext, controller.OperationWaitContext); + }; + + environment.Button( + CharacterManagementUiController.RestoreElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.RestoreCalls); + Assert.Equal(observedContext, controller.OperationWaitContext); + Assert.Equal( + 1, + environment.DialogLayouts.Count(static entry => + entry.Type == RetailDialogType.Wait)); + Assert.Same( + environment.LastDialog(RetailDialogType.Wait).Root, + environment.Host.Modal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Restore_ImmediateRejectionOrFailure_ClosesPreopenedWait( + bool throwFailure) + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + controller.Rows[2].OnClick!(); + environment.Runtime.RestoreStatus = RuntimeCommandStatus.Rejected; + environment.Runtime.ThrowOnRestore = throwFailure; + environment.Runtime.BeforeRestore = () => + { + Assert.NotEqual(0u, controller.OperationWaitContext); + Assert.NotNull(environment.Host.Modal); + }; + + Exception? error = Record.Exception(() => environment.Button( + CharacterManagementUiController.RestoreElementId).OnClick!()); + + Assert.Null(error); + Assert.Equal(1, environment.Runtime.RestoreCalls); + Assert.Equal(0u, controller.OperationWaitContext); + Assert.False(environment.Dialogs.IsOpen); + Assert.Null(environment.Host.Modal); + Assert.Equal( + RuntimeCharacterSelectionOperation.None, + environment.Runtime.View.Snapshot.Operation); + } + [Fact] public void MissingOrDisposedBorrowedView_ClosesDialogsFlushesRowsAndDisposesSafely() { @@ -475,6 +670,12 @@ public sealed class CharacterManagementUiControllerTests public int EnterCalls { get; private set; } public int ConfirmDeleteCalls { get; private set; } public int CancelCalls { get; private set; } + public int RestoreCalls { get; private set; } + public RuntimeCommandStatus RestoreStatus { get; set; } = + RuntimeCommandStatus.Accepted; + public bool ThrowOnRestore { get; set; } + public Action? BeforeRestore { get; set; } + public Action? AfterRestoreProjection { get; set; } public void SetOperation(RuntimeCharacterSelectionOperation operation) { @@ -578,7 +779,13 @@ public sealed class CharacterManagementUiControllerTests private RuntimeCommandResult Restore() { + RestoreCalls++; uint id = View.Snapshot.HighlightedCharacterId; + BeforeRestore?.Invoke(); + if (ThrowOnRestore) + throw new InvalidOperationException("restore transport failed"); + if (RestoreStatus != RuntimeCommandStatus.Accepted) + return Result(RestoreStatus, id); Update(snapshot => snapshot with { LastRestoreRequestedCharacterId = id, @@ -590,6 +797,7 @@ public sealed class CharacterManagementUiControllerTests false, true), }); + AfterRestoreProjection?.Invoke(); return Result(RuntimeCommandStatus.Accepted, id); } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 5e495c0c..76510dd9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -440,6 +440,108 @@ public sealed class RetailDialogFactoryTests Assert.Null(root.KeyboardFocus); } + [Theory] + [InlineData(RetailDialogType.Wait, 0)] + [InlineData(RetailDialogType.Message, 1)] + [InlineData(RetailDialogType.ConfirmationTextInput, 2)] + public void CatalogFailure_DoesNotPoisonActiveQueue_AndTickRecovers( + RetailDialogType type, + int failureKind) + { + var root = new UiRoot { Width = 800f, Height = 600f }; + bool available = false; + int attempts = 0; + using var factory = new RetailDialogFactory(root, requested => + { + Assert.Equal(type, requested); + attempts++; + if (!available) + { + return failureKind switch + { + 0 => throw new InvalidOperationException("catalog unavailable"), + 1 => null, + _ => new ImportedLayout( + new UiDialogRoot(), + new Dictionary()), + }; + } + return BuildDialogLayout(type); + }); + RetailDialogData data = type switch + { + RetailDialogType.Wait => RetailDialogData.Wait("Please Wait"), + RetailDialogType.Message => RetailDialogData.Message("Error"), + _ => RetailDialogData.ConfirmationTextInput("Type DELETE"), + }; + + uint context = 0u; + Exception? creationError = Record.Exception( + () => context = factory.MakeDialog(data)); + + Assert.Null(creationError); + Assert.NotEqual(0u, context); + Assert.Equal(0, factory.ActiveCount); + Assert.Equal(0, factory.PendingCount); + Assert.Equal(1, factory.RetryCount); + Assert.Null(root.Modal); + Assert.Empty(root.Children); + + available = true; + factory.Tick(); + + Assert.Equal(2, attempts); + Assert.Equal(1, factory.ActiveCount); + Assert.Equal(0, factory.PendingCount); + Assert.Equal(0, factory.RetryCount); + Assert.NotNull(root.Modal); + Assert.True(factory.CloseDialog(context)); + Assert.False(factory.IsOpen); + } + + [Fact] + public void PendingCatalogFailure_MovesOutOfQueue_ThenRecoversBeforeLaterWork() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + bool messageAvailable = false; + var layouts = new List<(RetailDialogType Type, ImportedLayout Layout)>(); + using var factory = new RetailDialogFactory(root, type => + { + if (type == RetailDialogType.Message && !messageAvailable) + return null; + ImportedLayout layout = BuildDialogLayout(type); + layouts.Add((type, layout)); + return layout; + }); + + uint active = factory.MakeWait("active"); + uint failed = factory.MakeMessage("recover me"); + uint later = factory.MakeWait("later"); + Assert.Equal(2, factory.PendingCount); + + Assert.True(factory.CloseDialog(active)); + + Assert.Equal(0, factory.ActiveCount); + Assert.Equal(1, factory.PendingCount); + Assert.Equal(1, factory.RetryCount); + Assert.Null(root.Modal); + + messageAvailable = true; + factory.Tick(); + + Assert.Equal(1, factory.ActiveCount); + Assert.Equal(1, factory.PendingCount); + Assert.Equal(0, factory.RetryCount); + Assert.Equal( + "recover me", + MessageFromAnyDialog(layouts.Last(static entry => + entry.Type == RetailDialogType.Message).Layout.Root)); + + Assert.True(factory.CloseDialog(failed)); + Assert.Equal("later", MessageFromAnyDialog(root.Modal!)); + Assert.True(factory.CloseDialog(later)); + } + private static RetailDialogFactory CreateFactory( UiRoot root, List layouts) @@ -464,6 +566,15 @@ public sealed class RetailDialogFactoryTests => string.Join(" ", Assert.IsType(layout.FindElement( RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)); + private static string MessageFromAnyDialog(UiElement root) + => string.Join( + " ", + Assert.IsType(UiElement.FindDescendant( + root, + RetailConfirmationDialogView.MessageElementId)) + .LinesProvider() + .Select(static line => line.Text)); + internal static ImportedLayout BuildDialogLayout(RetailDialogType type) { uint rootId = RetailDialogFactory.RootElementId(type); From 208a70ac83f4393be5c081d795ca06606f30afb6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 21:02:27 +0200 Subject: [PATCH 045/138] fix(launcher): guard orphan bake publication --- docs/architecture/acdream-architecture.md | 10 +- src/AcDream.Bake/AcDream.Bake.csproj | 2 + src/AcDream.Bake/BakeOutputTransaction.cs | 23 ++ src/AcDream.Bake/BakePublicationGuard.cs | 80 +++++++ .../AcDream.Launcher.Core.csproj | 1 + .../Installation/BakeOutputStagingContract.cs | 18 +- .../Installation/BakeProcessRunner.cs | 54 +++-- .../BakePublicationGuardContract.cs | 120 ++++++++++ .../Installation/LauncherInstaller.cs | 110 ++++++++-- .../BakePublicationGuardPaths.cs | 35 +++ ...e.Tests.Fixtures.InstallLeaseHolder.csproj | 5 + .../Program.cs | 206 ++++++++++++++++-- .../Installation/BakeProcessRunnerTests.cs | 46 ++++ .../Installation/LauncherInstallerTests.cs | 174 +++++++++++++++ 14 files changed, 829 insertions(+), 55 deletions(-) create mode 100644 src/AcDream.Bake/BakePublicationGuard.cs create mode 100644 src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs create mode 100644 src/AcDream.Platform/BakePublicationGuardPaths.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 34c9d23b..aab28285 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -278,6 +278,9 @@ src/ AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0) ApplicationPathSet.cs -> shared XDG/Windows config, data, cache, plugin, screenshot, and diagnostic paths + BakePublicationGuardPaths.cs + -> shared launcher/Bake environment nonce and + adjacent publication lock/token naming contract -> zero project/package references (guarded by tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); Runtime and App reference it directly; Headless reaches it @@ -295,7 +298,12 @@ src/ orchestration, and atomic SHA/size/tool-version install-record verification and recovery; one OS-handle lease serializes recovery/install per - DataDirectory, and only exact adjacent + DataDirectory; a second OS-held publication + lock plus durable per-transaction nonce makes + late orphan Bake children irrevocably stale + before recovery, while already-authorized + promotion completes before recovery; only exact + adjacent `..acdream-bake..tmp` files are transaction-owned crash residue -> references Platform only; no Avalonia or game-host dependency diff --git a/src/AcDream.Bake/AcDream.Bake.csproj b/src/AcDream.Bake/AcDream.Bake.csproj index 9b04f741..3557add4 100644 --- a/src/AcDream.Bake/AcDream.Bake.csproj +++ b/src/AcDream.Bake/AcDream.Bake.csproj @@ -12,6 +12,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/AcDream.Bake/BakeOutputTransaction.cs b/src/AcDream.Bake/BakeOutputTransaction.cs index 54416ab5..34f5cf16 100644 --- a/src/AcDream.Bake/BakeOutputTransaction.cs +++ b/src/AcDream.Bake/BakeOutputTransaction.cs @@ -16,6 +16,21 @@ public static class BakeOutputTransaction Func writeTemporary, Action validateTemporary, CancellationToken cancellationToken = default) + => WriteValidateAndPublish( + destinationPath, + writeTemporary, + validateTemporary, + beforePublicationLock: null, + beforePromotion: null, + cancellationToken); + + internal static TResult WriteValidateAndPublish( + string destinationPath, + Func writeTemporary, + Action validateTemporary, + Action? beforePublicationLock, + Action? beforePromotion, + CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); ArgumentNullException.ThrowIfNull(writeTemporary); @@ -36,6 +51,14 @@ public static class BakeOutputTransaction cancellationToken.ThrowIfCancellationRequested(); validateTemporary(temporaryPath, result); cancellationToken.ThrowIfCancellationRequested(); + beforePublicationLock?.Invoke(); + using IDisposable? publication = + BakePublicationGuard.AcquireIfRequested( + fullDestination, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + beforePromotion?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); // Same-volume MoveFileEx/rename is the publication primitive. // File.Replace additionally performs destination metadata/backup diff --git a/src/AcDream.Bake/BakePublicationGuard.cs b/src/AcDream.Bake/BakePublicationGuard.cs new file mode 100644 index 00000000..0b35a811 --- /dev/null +++ b/src/AcDream.Bake/BakePublicationGuard.cs @@ -0,0 +1,80 @@ +using AcDream.Platform; + +namespace AcDream.Bake; + +/// +/// Optional launcher authorization checked immediately before atomic +/// publication. Standalone Bake runs have no nonce environment variable and +/// retain the original unguarded behavior. +/// +internal static class BakePublicationGuard +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static IDisposable? AcquireIfRequested( + string outputPath, + CancellationToken cancellationToken) + { + string? nonce = Environment.GetEnvironmentVariable( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (nonce is null) + { + return null; + } + + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new InvalidOperationException( + "The launcher bake publication nonce is invalid."); + } + + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + FileStream? lease = null; + while (lease is null) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None); + } + catch (IOException) + { + cancellationToken.WaitHandle.WaitOne(RetryDelay); + } + } + + try + { + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + string authorized = File.Exists(authorizationPath) + ? File.ReadAllText(authorizationPath) + : string.Empty; + if (!string.Equals(authorized, nonce, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "This bake process is no longer authorized to publish its output."); + } + + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + +} diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj index 85dc49f0..df9c9ad1 100644 --- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs index a6ae15b4..cefa567e 100644 --- a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs +++ b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs @@ -51,14 +51,22 @@ internal static class BakeOutputStagingContract } string destinationFileName = Path.GetFileName(fullDestination); - foreach (string candidate in Directory.EnumerateFiles(directory)) + try { - if (IsOwnedStagingFileName( - Path.GetFileName(candidate), - destinationFileName)) + foreach (string candidate in Directory.EnumerateFiles(directory)) { - LauncherInstallRecordStore.TryDelete(candidate); + if (IsOwnedStagingFileName( + Path.GetFileName(candidate), + destinationFileName)) + { + LauncherInstallRecordStore.TryDelete(candidate); + } } } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort: these files are never launchable. A later startup + // retries exact-name cleanup under the publication lock. + } } } diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs index 7c11ea15..24fbc48c 100644 --- a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using AcDream.Platform; namespace AcDream.Launcher.Core.Installation; @@ -9,7 +10,8 @@ public sealed record BakeProcessRequest( string ExecutablePath, string DatDirectory, string OutputPath, - int Threads) + int Threads, + string? PublicationNonce = null) { public IReadOnlyList Arguments => [ @@ -59,19 +61,7 @@ public sealed class SystemBakeProcessRunner : IBakeProcessRunner cancellationToken.ThrowIfCancellationRequested(); - var startInfo = new ProcessStartInfo - { - FileName = request.ExecutablePath, - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - foreach (string argument in request.Arguments) - { - startInfo.ArgumentList.Add(argument); - } + ProcessStartInfo startInfo = CreateStartInfo(request); using var process = new Process { StartInfo = startInfo }; if (!process.Start()) @@ -140,6 +130,42 @@ public sealed class SystemBakeProcessRunner : IBakeProcessRunner } } + internal static ProcessStartInfo CreateStartInfo(BakeProcessRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var startInfo = new ProcessStartInfo + { + FileName = request.ExecutablePath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in request.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + startInfo.Environment.Remove( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (request.PublicationNonce is not null) + { + if (!BakePublicationGuardPaths.IsValidNonce( + request.PublicationNonce)) + { + throw new ArgumentException( + "The bake publication nonce is invalid.", + nameof(request)); + } + + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable] = + request.PublicationNonce; + } + + return startInfo; + } + private static async Task PumpAsync( TextReader reader, Action sink, diff --git a/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs new file mode 100644 index 00000000..a3f409c6 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs @@ -0,0 +1,120 @@ +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// Launcher half of the environment-only Bake publication guard. Paths are +/// derived from the canonical output path, while a durable GUID nonce grants +/// one child permission to promote its already-validated adjacent staging +/// file. Every token mutation happens while the stable publication lock is +/// held. +/// +internal static class BakePublicationGuardContract +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static async ValueTask AcquireAsync( + string outputPath, + CancellationToken cancellationToken = default) + { + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return new PublicationLease(new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None)); + } + catch (IOException) + { + await Task.Delay(RetryDelay, cancellationToken) + .ConfigureAwait(false); + } + } + } + + internal static void Authorize( + string outputPath, + string nonce, + PublicationLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new ArgumentException( + "The bake publication nonce must be a lowercase GUID in N format.", + nameof(nonce)); + } + + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + using var stream = new FileStream( + authorizationPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.WriteThrough); + using var writer = new StreamWriter(stream, leaveOpen: true); + writer.Write(nonce); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + internal static void Invalidate( + string outputPath, + PublicationLease lease, + string? onlyIfNonceMatches = null) + { + ArgumentNullException.ThrowIfNull(lease); + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + if (!File.Exists(authorizationPath)) + { + return; + } + + if (onlyIfNonceMatches is not null) + { + string current = File.ReadAllText(authorizationPath); + + if (!string.Equals( + current, + onlyIfNonceMatches, + StringComparison.Ordinal)) + { + return; + } + } + + File.Delete(authorizationPath); + } + + internal sealed class PublicationLease : IAsyncDisposable + { + private readonly FileStream _stream; + + internal PublicationLease(FileStream stream) + { + _stream = stream; + } + + public ValueTask DisposeAsync() + { + _stream.Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 488f03f5..f8de24a7 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -120,10 +120,9 @@ public sealed class LauncherInstaller : ILauncherInstaller _recordStore.DataDirectory, cancellationToken) .ConfigureAwait(false); - BakeOutputStagingContract.DeleteOwnedStagingFiles( - _recordStore.PreparedAssetPath); - InstallRecordVerification verification = await _recordStore - .LoadAndVerifyUnderLeaseAsync(cancellationToken) + InstallRecordVerification verification = + await RecoverExistingUnderPublicationGuardAsync( + cancellationToken) .ConfigureAwait(false); _verifiedRecord = verification.Record; return verification; @@ -197,9 +196,8 @@ public sealed class LauncherInstaller : ILauncherInstaller string outputPath = _recordStore.PreparedAssetPath; string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); - BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); - InstallRecordVerification existing = await _recordStore - .LoadAndVerifyUnderLeaseAsync(cancellationToken) + InstallRecordVerification existing = + await RecoverExistingUnderPublicationGuardAsync(cancellationToken) .ConfigureAwait(false); _verifiedRecord = existing.Record; @@ -220,6 +218,7 @@ public sealed class LauncherInstaller : ILauncherInstaller var parser = new BakeProgressJsonlParser(); var protocol = new BakeProgressProtocol(); + string? publicationNonce = null; void Observe(BakeProgressEvent progressEvent) { @@ -265,11 +264,26 @@ public sealed class LauncherInstaller : ILauncherInstaller try { cancellationToken.ThrowIfCancellationRequested(); + publicationNonce = BakePublicationGuardPaths.CreateNonce(); + await using ( + BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false)) + { + BakePublicationGuardContract.Authorize( + outputPath, + publicationNonce, + publication); + } + var request = new BakeProcessRequest( _bakeExecutablePath, validation.Directory, outputPath, - threads); + threads, + publicationNonce); BakeProcessResult processResult = await _processRunner.RunAsync( request, chunk => @@ -370,7 +384,11 @@ public sealed class LauncherInstaller : ILauncherInstaller .ConfigureAwait(false); _verifiedRecord = record; - LauncherInstallRecordStore.TryDelete(backupPath); + await FinalizeSuccessfulPublicationAsync( + outputPath, + backupPath, + publicationNonce) + .ConfigureAwait(false); Report( progress, LauncherInstallPhase.Completed, @@ -381,7 +399,12 @@ public sealed class LauncherInstaller : ILauncherInstaller } catch (OperationCanceledException) { - RestorePreviousPackage(outputPath, backupPath, previousPreserved); + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); Report( progress, LauncherInstallPhase.Cancelled, @@ -390,7 +413,12 @@ public sealed class LauncherInstaller : ILauncherInstaller } catch (Exception ex) { - RestorePreviousPackage(outputPath, backupPath, previousPreserved); + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); Report( progress, LauncherInstallPhase.Failed, @@ -402,10 +430,62 @@ public sealed class LauncherInstaller : ILauncherInstaller throw new LauncherInstallException("Installation failed.", ex); } - finally - { - BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); - } + } + + private async Task + RecoverExistingUnderPublicationGuardAsync( + CancellationToken cancellationToken) + { + string outputPath = _recordStore.PreparedAssetPath; + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false); + // Any child whose parent died before it acquired this lock is now + // irrevocably stale. A child already holding the lock must finish its + // promotion before recovery reaches this invalidation point. + BakePublicationGuardContract.Invalidate(outputPath, publication); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + private static async Task FinalizeSuccessfulPublicationAsync( + string outputPath, + string backupPath, + string publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + LauncherInstallRecordStore.TryDelete(backupPath); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + } + + private static async Task FinalizeFailedPublicationAsync( + string outputPath, + string backupPath, + bool previousPreserved, + string? publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + RestorePreviousPackage(outputPath, backupPath, previousPreserved); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); } private bool PreservePreviousPackage(string outputPath, string backupPath) diff --git a/src/AcDream.Platform/BakePublicationGuardPaths.cs b/src/AcDream.Platform/BakePublicationGuardPaths.cs new file mode 100644 index 00000000..75f46ae3 --- /dev/null +++ b/src/AcDream.Platform/BakePublicationGuardPaths.cs @@ -0,0 +1,35 @@ +namespace AcDream.Platform; + +/// +/// Portable, versioned naming contract shared by the launcher parent and the +/// independently published bake child. The durable token grants one child +/// permission to publish while the adjacent OS-held lock serializes its final +/// promotion with launcher recovery. +/// +public static class BakePublicationGuardPaths +{ + public const string NonceEnvironmentVariable = + "ACDREAM_BAKE_PUBLISH_NONCE_V1"; + public const string PublishLockSuffix = ".publish.lock"; + public const string AuthorizationSuffix = ".publish-token"; + + public static string CreateNonce() => Guid.NewGuid().ToString("N"); + + public static bool IsValidNonce(string? nonce) => + nonce is not null + && nonce.Length == 32 + && Guid.TryParseExact(nonce, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), nonce, StringComparison.Ordinal); + + public static string GetPublishLockPath(string outputPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + return Path.GetFullPath(outputPath) + PublishLockSuffix; + } + + public static string GetAuthorizationPath(string outputPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + return Path.GetFullPath(outputPath) + AuthorizationSuffix; + } +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj index bc7176f0..b64e0eaa 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj @@ -8,4 +8,9 @@ false true + + + + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index 73207808..c56f94cd 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -1,24 +1,190 @@ -if (args.Length != 3) +using System.Diagnostics; +using System.Reflection; +using AcDream.Bake; +using AcDream.Launcher.Core.Installation; +using AcDream.Platform; + +return args.FirstOrDefault() switch { - return 2; + "hold-install-lease" => await HoldInstallLeaseAsync(args[1..]), + "orphan-parent" => await RunOrphanParentAsync(args[1..]), + "orphan-child" => RunOrphanChild(args[1..]), + _ => 2, +}; + +static async Task HoldInstallLeaseAsync(string[] arguments) +{ + if (arguments.Length != 3) + { + return 2; + } + + string lockPath = Path.GetFullPath(arguments[0]); + string stagingPath = Path.GetFullPath(arguments[1]); + string readyPath = Path.GetFullPath(arguments[2]); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException("lock path has no parent")); + Directory.CreateDirectory( + Path.GetDirectoryName(stagingPath) + ?? throw new InvalidOperationException("staging path has no parent")); + + using var lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + File.WriteAllText(stagingPath, "abandoned bake staging"); + File.WriteAllText(readyPath, "ready"); + await Task.Delay(Timeout.InfiniteTimeSpan); + return 0; } -string lockPath = Path.GetFullPath(args[0]); -string stagingPath = Path.GetFullPath(args[1]); -string readyPath = Path.GetFullPath(args[2]); -Directory.CreateDirectory( - Path.GetDirectoryName(lockPath) - ?? throw new InvalidOperationException("lock path has no parent")); -Directory.CreateDirectory( - Path.GetDirectoryName(stagingPath) - ?? throw new InvalidOperationException("staging path has no parent")); +static async Task RunOrphanParentAsync(string[] arguments) +{ + if (arguments.Length != 8) + { + return 2; + } -using var lease = new FileStream( - lockPath, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.None); -File.WriteAllText(stagingPath, "abandoned bake staging"); -File.WriteAllText(readyPath, "ready"); -await Task.Delay(Timeout.InfiniteTimeSpan); -return 0; + string dataDirectory = Path.GetFullPath(arguments[0]); + string datDirectory = Path.GetFullPath(arguments[1]); + string bakeMarker = Path.GetFullPath(arguments[2]); + string schedule = arguments[3]; + string childReadyPath = Path.GetFullPath(arguments[4]); + string childReleasePath = Path.GetFullPath(arguments[5]); + string childPidPath = Path.GetFullPath(arguments[6]); + string childExitPath = Path.GetFullPath(arguments[7]); + var paths = new ApplicationPathSet( + Path.Combine(dataDirectory, "fixture-config"), + dataDirectory, + Path.Combine(dataDirectory, "fixture-cache"), + null); + var runner = new OrphanBakeProcessRunner( + schedule, + childReadyPath, + childReleasePath, + childPidPath, + childExitPath); + var installer = new LauncherInstaller( + paths, + bakeMarker, + processRunner: runner); + + try + { + await installer.InstallAsync(datDirectory, 1); + return 0; + } + catch + { + return 9; + } +} + +static int RunOrphanChild(string[] arguments) +{ + if (arguments.Length != 5) + { + return 2; + } + + string outputPath = Path.GetFullPath(arguments[0]); + string schedule = arguments[1]; + string readyPath = Path.GetFullPath(arguments[2]); + string releasePath = Path.GetFullPath(arguments[3]); + string exitPath = Path.GetFullPath(arguments[4]); + Action barrier = () => + { + File.WriteAllText(readyPath, schedule); + while (!File.Exists(releasePath)) + { + Thread.Sleep(10); + } + }; + + int exitCode; + try + { + BakeOutputTransaction.WriteValidateAndPublish( + outputPath, + temporaryPath => + { + File.WriteAllText(temporaryPath, "orphan replacement"); + return 1; + }, + (temporaryPath, _) => + { + if (File.ReadAllText(temporaryPath) != "orphan replacement") + { + throw new InvalidDataException("staging content changed"); + } + }, + beforePublicationLock: schedule == "late" ? barrier : null, + beforePromotion: schedule == "holds" ? barrier : null, + CancellationToken.None); + exitCode = 0; + } + catch (Exception ex) + { + File.WriteAllText(exitPath + ".error", ex.Message); + exitCode = 17; + } + + File.WriteAllText(exitPath, exitCode.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + return exitCode; +} + +file sealed class OrphanBakeProcessRunner( + string schedule, + string childReadyPath, + string childReleasePath, + string childPidPath, + string childExitPath) : IBakeProcessRunner +{ + public async Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default) + { + string dotnetHost = Environment.ProcessPath + ?? throw new InvalidOperationException("dotnet host path is unavailable"); + string fixtureDll = Assembly.GetExecutingAssembly().Location; + var startInfo = new ProcessStartInfo(dotnetHost) + { + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(fixtureDll); + startInfo.ArgumentList.Add("orphan-child"); + startInfo.ArgumentList.Add(request.OutputPath); + startInfo.ArgumentList.Add(schedule); + startInfo.ArgumentList.Add(childReadyPath); + startInfo.ArgumentList.Add(childReleasePath); + startInfo.ArgumentList.Add(childExitPath); + startInfo.Environment.Remove( + BakePublicationGuardPaths.NonceEnvironmentVariable); + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable] = + request.PublicationNonce + ?? throw new InvalidOperationException("publication nonce is missing"); + + using Process child = Process.Start(startInfo) + ?? throw new InvalidOperationException("orphan child did not start"); + File.WriteAllText( + childPidPath, + child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + await child.WaitForExitAsync(cancellationToken); + if (child.ExitCode == 0) + { + long bytes = new FileInfo(request.OutputPath).Length; + onStandardOutput("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n"); + onStandardOutput($"{{\"v\":1,\"e\":\"completed\"," + + $"\"bakeToolVersion\":4,\"outputBytes\":{bytes}," + + "\"failures\":0}\n"); + } + + return new BakeProcessResult(child.ExitCode, "orphan fixture child"); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs new file mode 100644 index 00000000..35dee17e --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Installation/BakeProcessRunnerTests.cs @@ -0,0 +1,46 @@ +using AcDream.Launcher.Core.Installation; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Tests.Installation; + +public sealed class BakeProcessRunnerTests +{ + [Fact] + public void PublicationNonceIsEnvironmentOnlyAndVisibleArgumentsStayPinned() + { + string nonce = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef") + .ToString("N"); + var request = new BakeProcessRequest( + "acdream-bake", + "retail-dats", + "data/pak/acdream.pak", + 7, + nonce); + + System.Diagnostics.ProcessStartInfo startInfo = + SystemBakeProcessRunner.CreateStartInfo(request); + + Assert.Equal(request.Arguments, startInfo.ArgumentList); + Assert.DoesNotContain(nonce, startInfo.ArgumentList); + Assert.Equal( + nonce, + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable]); + } + + [Fact] + public void UnguardedRequestExplicitlyRemovesInheritedAuthorization() + { + var request = new BakeProcessRequest( + "acdream-bake", + "retail-dats", + "data/pak/acdream.pak", + 1); + + System.Diagnostics.ProcessStartInfo startInfo = + SystemBakeProcessRunner.CreateStartInfo(request); + + Assert.False(startInfo.Environment.ContainsKey( + BakePublicationGuardPaths.NonceEnvironmentVariable)); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs index 0ee05773..4e48b069 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs @@ -85,6 +85,11 @@ public sealed class LauncherInstallerTests : IDisposable "--progress-json", ], observedRequest.Arguments); + Assert.True(BakePublicationGuardPaths.IsValidNonce( + observedRequest.PublicationNonce)); + Assert.DoesNotContain( + observedRequest.PublicationNonce!, + observedRequest.Arguments); Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion, result.Record.BakeToolVersion); Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length, @@ -94,6 +99,9 @@ public sealed class LauncherInstallerTests : IDisposable result.Record.PreparedAssetSha256); Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes); Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase); + Assert.False(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + result.Record.PreparedAssetPath))); var store = new LauncherInstallRecordStore(_paths); InstallRecordVerification verification = await store.LoadAndVerifyAsync(); @@ -434,6 +442,7 @@ public sealed class LauncherInstallerTests : IDisposable UseShellExecute = false, }; startInfo.ArgumentList.Add(fixtureDll); + startInfo.ArgumentList.Add("hold-install-lease"); startInfo.ArgumentList.Add( InstallerTransactionLease.GetLockPath(store.DataDirectory)); startInfo.ArgumentList.Add(staging); @@ -479,6 +488,144 @@ public sealed class LauncherInstallerTests : IDisposable await File.ReadAllTextAsync(store.PreparedAssetPath)); } + [Theory] + [InlineData("holds", 0)] + [InlineData("late", 17)] + public async Task OrphanBakeCanNeverPublishAfterRestartRecovery( + string schedule, + int expectedChildExitCode) + { + var store = new LauncherInstallRecordStore(_paths); + LauncherInstallRecord old = await CreatePriorRecordAsync(store); + string recordBefore = await File.ReadAllTextAsync(store.RecordPath); + string control = Path.Combine(_root, "orphan-" + schedule); + Directory.CreateDirectory(control); + string ready = Path.Combine(control, "child-ready"); + string release = Path.Combine(control, "child-release"); + string childPid = Path.Combine(control, "child-pid"); + string childExit = Path.Combine(control, "child-exit"); + string fixtureDll = GetInstallLeaseFixturePath(); + var startInfo = new ProcessStartInfo("dotnet") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + foreach (string argument in new[] + { + fixtureDll, + "orphan-parent", + store.DataDirectory, + _dats, + _bakeExecutable, + schedule, + ready, + release, + childPid, + childExit, + }) + { + startInfo.ArgumentList.Add(argument); + } + + using Process parent = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start orphan parent."); + int orphanPid = 0; + try + { + await WaitForFileAsync(ready, parent, TimeSpan.FromSeconds(15)); + orphanPid = int.Parse( + await File.ReadAllTextAsync(childPid), + System.Globalization.CultureInfo.InvariantCulture); + Assert.True(File.Exists( + LauncherInstallRecordStore.GetBackupPath( + store.PreparedAssetPath))); + Assert.True(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + store.PreparedAssetPath))); + + parent.Kill(entireProcessTree: false); + await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + var restarted = new LauncherInstaller( + _paths, + _bakeExecutable, + recordStore: new LauncherInstallRecordStore(_paths)); + Task recovery = + restarted.LoadExistingAsync(); + InstallRecordVerification recovered; + if (schedule == "holds") + { + await Task.Delay(200); + Assert.False(recovery.IsCompleted); + File.WriteAllText(release, "release"); + recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15)); + } + else + { + recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.False(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + store.PreparedAssetPath))); + File.WriteAllText(release, "release"); + } + + Assert.True(recovered.IsVerified); + Assert.Equal(old, recovered.Record); + string canonicalAfterRecovery = + await File.ReadAllTextAsync(store.PreparedAssetPath); + string recordAfterRecovery = + await File.ReadAllTextAsync(store.RecordPath); + bool backupAfterRecovery = File.Exists( + LauncherInstallRecordStore.GetBackupPath( + store.PreparedAssetPath)); + + await WaitForFileAsync(childExit, TimeSpan.FromSeconds(15)); + Assert.Equal( + expectedChildExitCode, + int.Parse( + await File.ReadAllTextAsync(childExit), + System.Globalization.CultureInfo.InvariantCulture)); + if (schedule == "late") + { + Assert.Contains( + "no longer authorized", + await File.ReadAllTextAsync(childExit + ".error"), + StringComparison.OrdinalIgnoreCase); + } + await Task.Delay(200); + + Assert.Equal( + canonicalAfterRecovery, + await File.ReadAllTextAsync(store.PreparedAssetPath)); + Assert.Equal("previous verified package", canonicalAfterRecovery); + Assert.Equal(recordBefore, recordAfterRecovery); + Assert.Equal(recordAfterRecovery, await File.ReadAllTextAsync(store.RecordPath)); + Assert.Equal( + backupAfterRecovery, + File.Exists(LauncherInstallRecordStore.GetBackupPath( + store.PreparedAssetPath))); + Assert.False(backupAfterRecovery); + Assert.False(File.Exists( + BakePublicationGuardPaths.GetAuthorizationPath( + store.PreparedAssetPath))); + } + finally + { + File.WriteAllText(release, "release"); + if (!parent.HasExited) + { + parent.Kill(entireProcessTree: false); + await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + + if (orphanPid != 0 && !File.Exists(childExit)) + { + TryKill(orphanPid); + } + } + } + private async Task<( LauncherInstaller Installer, LauncherInstallRecordStore Store, @@ -548,6 +695,33 @@ public sealed class LauncherInstallerTests : IDisposable } } + private static async Task WaitForFileAsync(string path, TimeSpan timeout) + { + using var cancellation = new CancellationTokenSource(timeout); + while (!File.Exists(path)) + { + await Task.Delay(25, cancellation.Token); + } + } + + private static void TryKill(int processId) + { + try + { + using Process process = Process.GetProcessById(processId); + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(5_000); + } + } + catch + { + // The orphan normally exits by itself; cleanup tolerates the + // expected race with Process.GetProcessById. + } + } + private static string GetInstallLeaseFixturePath() { string root = FindRepositoryRoot(); From 1dd5706e156c8563434de523e31a463308ef0f99 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 21:14:08 +0200 Subject: [PATCH 046/138] Make character UI retries transactional --- .../2026-08-14-la8-character-management-ui.md | 7 +- .../Layout/CharacterManagementUiController.cs | 105 ++++++++++++----- .../CharacterManagementUiMountCoordinator.cs | 35 ++++-- .../UI/Layout/RetailDialogFactory.cs | 67 +++++++++-- .../CharacterManagementUiControllerTests.cs | 71 ++++++++++++ .../UI/Layout/RetailDialogFactoryTests.cs | 109 ++++++++++++++++++ 6 files changed, 352 insertions(+), 42 deletions(-) diff --git a/docs/research/2026-08-14-la8-character-management-ui.md b/docs/research/2026-08-14-la8-character-management-ui.md index 1bde915c..a769c5e0 100644 --- a/docs/research/2026-08-14-la8-character-management-ui.md +++ b/docs/research/2026-08-14-la8-character-management-ui.md @@ -104,7 +104,12 @@ retries on the next frame. Initial dialog-catalog, character root, and string misses likewise retry on later ticks without mounting a duplicate root or controller. Dialog presenter/catalog failures move their contexts to an internal retry ledger, so UI callbacks do not retain poisoned active/queued -entries and the same context can appear after resource recovery. +entries and the same context can appear after resource recovery. Priority +contexts remain ahead of ordinary retries and preserve retail's nested +preemption order when creation recovers. The mount coordinator owns a detached +controller before attaching its root or running the first template-resolving +tick; any partial failure disposes that exact controller before retry, so roots +and handlers cannot accumulate. There is deliberately no 3D preview and no claimed character-select background scene. The screen root remains neutral with respect to render-loop background diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index 02f27ce5..cd5aa341 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -116,6 +116,36 @@ internal sealed class CharacterManagementUiController : IDisposable RetailDialogFactory dialogs, CharacterSelectionRuntimeBindings bindings, DialogStrings strings) + { + CharacterManagementUiController? controller = CreateDetached( + host, + layout, + templateResolver, + dialogs, + bindings, + strings); + if (controller is null) + return null; + + try + { + controller.AttachAndTick(); + return controller; + } + catch + { + controller.Dispose(); + throw; + } + } + + internal static CharacterManagementUiController? CreateDetached( + UiRoot host, + ImportedLayout layout, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterSelectionRuntimeBindings bindings, + DialogStrings strings) { ArgumentNullException.ThrowIfNull(host); ArgumentNullException.ThrowIfNull(layout); @@ -144,20 +174,37 @@ internal sealed class CharacterManagementUiController : IDisposable } list.TemplateResolver = templateResolver; - var controller = new CharacterManagementUiController( - host, - layout, - list, - create, - enter, - delete, - restore, - dialogs, - bindings, - strings); - host.AddChild(controller.Root); - controller.Tick(); - return controller; + try + { + return new CharacterManagementUiController( + host, + layout, + list, + create, + enter, + delete, + restore, + dialogs, + bindings, + strings); + } + catch + { + list.TemplateResolver = null; + create.OnClick = null; + enter.OnClick = null; + delete.OnClick = null; + restore.OnClick = null; + throw; + } + } + + internal void AttachAndTick() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Root.Parent is null) + _host.AddChild(Root); + Tick(); } private static bool ContainsViewport(UiElement element) @@ -247,20 +294,26 @@ internal sealed class CharacterManagementUiController : IDisposable if (_disposed) return; _disposed = true; - CloseAllDialogs(suppressCallbacks: true); - _enter.OnClick = null; - _delete.OnClick = null; - _restore.OnClick = null; - foreach (UiButton row in _rows) + try { - row.OnClick = null; - row.OnDoubleClick = null; + CloseAllDialogs(suppressCallbacks: true); + } + finally + { + _enter.OnClick = null; + _delete.OnClick = null; + _restore.OnClick = null; + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + _list.TemplateResolver = null; + _host.RemoveChild(Root); } - _rows.Clear(); - _rowIds.Clear(); - _list.Flush(); - _list.TemplateResolver = null; - _host.RemoveChild(Root); } private static bool TryCaptureRoster( diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs index e297d5ac..e2748193 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs @@ -53,23 +53,42 @@ internal sealed class CharacterManagementUiMountCoordinator : IDisposable if (resources is null) return; - Controller = CharacterManagementUiController.Bind( + CharacterManagementUiController? candidate = + CharacterManagementUiController.CreateDetached( _host, resources.Layout, resources.TemplateResolver, dialogs, _bindings, resources.Strings); - if (Controller is not null) - { - Console.WriteLine( - $"[UI] retail character management from enum table 5 " - + $"(0x10000005 -> 0x{resources.LayoutId:X8}, " - + "root 0x1000039A; flat list, no viewport)."); - } + if (candidate is null) + return; + + // Take ownership before the first attach/tick. Template resolution + // happens inside that tick and can throw after the root and button + // handlers are live; the catch below can therefore always retire + // the exact partial controller before a later retry. + Controller = candidate; + candidate.AttachAndTick(); + Console.WriteLine( + $"[UI] retail character management from enum table 5 " + + $"(0x10000005 -> 0x{resources.LayoutId:X8}, " + + "root 0x1000039A; flat list, no viewport)."); } catch (Exception error) { + CharacterManagementUiController? partial = Controller; + Controller = null; + try + { + partial?.Dispose(); + } + catch (Exception cleanupError) + { + Console.WriteLine( + "[UI] character management partial-mount cleanup failed: " + + cleanupError.Message); + } Console.WriteLine( "[UI] character management mount will retry after resource " + $"recovery: {error.Message}"); diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index f631adb1..f72d7283 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -15,6 +15,7 @@ public sealed class RetailDialogFactory : IDisposable public required RetailDialogData Data { get; init; } public required uint Context { get; init; } public required uint QueueKey { get; init; } + public required ulong Sequence { get; init; } public Action? Callback { get; init; } public IRetailDialogView? View { get; set; } } @@ -27,6 +28,7 @@ public sealed class RetailDialogFactory : IDisposable private readonly LinkedList _retryable = new(); private readonly List _openOrder = new(); private uint _globalContext; + private ulong _globalSequence; private bool _resetting; private bool _disposed; @@ -90,6 +92,7 @@ public sealed class RetailDialogFactory : IDisposable Data = ownedData, Context = context, QueueKey = queueKey, + Sequence = NextSequence(), Callback = callback, }; @@ -106,7 +109,7 @@ public sealed class RetailDialogFactory : IDisposable if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current)) { - if (HasRetry(queueKey)) + if (HasRetry(queueKey) && !IsPriority(info)) { PendingQueue(queueKey).AddLast(info); return context; @@ -122,7 +125,7 @@ public sealed class RetailDialogFactory : IDisposable } LinkedList queue = PendingQueue(queueKey); - if (!ownedData.GetBoolean(RetailDialogProperty.Priority)) + if (!IsPriority(info)) { queue.AddLast(info); UpdatePendingDialogDisplays(); @@ -324,6 +327,14 @@ public sealed class RetailDialogFactory : IDisposable return _globalContext; } + private ulong NextSequence() + { + _globalSequence++; + if (_globalSequence == 0uL) + _globalSequence++; + return _globalSequence; + } + private LinkedList PendingQueue(uint queueKey) { if (_pending.TryGetValue(queueKey, out LinkedList? queue)) @@ -480,14 +491,37 @@ public sealed class RetailDialogFactory : IDisposable continue; } - if (!_activeQueued.ContainsKey(info.QueueKey) - && ReferenceEquals(FirstRetry(info.QueueKey), info)) - { + if (!ReferenceEquals(FirstRetry(info.QueueKey), info)) + continue; + + if (!_activeQueued.TryGetValue( + info.QueueKey, + out DialogInfo? active)) TryActivateRetry(info.QueueKey); - } + else if (IsPriority(info) + && (!IsPriority(active) || info.Sequence > active.Sequence)) + TryPreemptWithRetry(info, active); } } + private void TryPreemptWithRetry(DialogInfo priority, DialogInfo current) + { + LinkedList queue = PendingQueue(priority.QueueKey); + Suspend(current); + queue.AddFirst(current); + _activeQueued[priority.QueueKey] = priority; + _retryable.Remove(priority); + if (TryCreateDialog(priority)) + return; + + _activeQueued.Remove(priority.QueueKey); + queue.Remove(current); + if (queue.Count == 0) + _pending.Remove(priority.QueueKey); + OpenSpecificDialog(current); + QueueRetry(priority); + } + private bool TryActivateRetry(uint queueKey) { DialogInfo? info = FirstRetry(queueKey); @@ -512,10 +546,29 @@ public sealed class RetailDialogFactory : IDisposable private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null; + private static bool IsPriority(DialogInfo info) => + info.Data.GetBoolean(RetailDialogProperty.Priority); + private void QueueRetry(DialogInfo info) { - if (!_retryable.Contains(info)) + if (_retryable.Contains(info)) + return; + if (!IsPriority(info)) + { _retryable.AddLast(info); + return; + } + + LinkedListNode? existing = _retryable.First; + while (existing is not null + && existing.Value.QueueKey != info.QueueKey) + { + existing = existing.Next; + } + if (existing is null) + _retryable.AddLast(info); + else + _retryable.AddBefore(existing, info); } private void UpdatePendingDialogDisplays() diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index 9cc8a664..d9708e38 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -188,6 +188,66 @@ public sealed class CharacterManagementUiControllerTests Assert.Equal(3, resourceAttempts); } + [Fact] + public void MountCoordinator_PostAttachFailuresDisposeBeforeRetryAndRecovery() + { + var host = new UiRoot { Width = 800f, Height = 600f }; + var runtime = new FakeRuntime(); + using var dialogs = new RetailDialogFactory( + host, + RetailDialogFactoryTests.BuildDialogLayout); + var screens = new List(); + int failingAttempts = 2; + using var coordinator = new CharacterManagementUiMountCoordinator( + host, + runtime.Bindings, + () => dialogs, + () => + { + ImportedLayout screen = BuildScreen(); + screens.Add(screen); + bool throwAfterAttach = failingAttempts-- > 0; + return new CharacterManagementUiMountResources( + 0x21000004u, + screen, + (_, _) => throwAfterAttach + ? throw new InvalidOperationException( + "template failed after root attach") + : BuildRow(), + TestStrings()); + }); + + coordinator.Tick(); + Assert.Null(coordinator.Controller); + Assert.Empty(host.Children); + Assert.Single(screens); + AssertDetachedAndUnbound(screens[0]); + + coordinator.Tick(); + Assert.Null(coordinator.Controller); + Assert.Empty(host.Children); + Assert.Equal(2, screens.Count); + Assert.All(screens, AssertDetachedAndUnbound); + + coordinator.Tick(); + CharacterManagementUiController mounted = Assert.IsType< + CharacterManagementUiController>(coordinator.Controller); + Assert.Equal(3, screens.Count); + Assert.Single(host.Children); + Assert.Same(mounted.Root, host.Children[0]); + Assert.NotNull(Assert.IsType(screens[2].FindElement( + CharacterManagementUiController.EnterElementId)).OnClick); + + coordinator.Tick(); + Assert.Equal(3, screens.Count); + Assert.Single(host.Children); + + coordinator.Dispose(); + Assert.Empty(host.Children); + Assert.Null(coordinator.Controller); + Assert.All(screens, AssertDetachedAndUnbound); + } + [Fact] public void DeleteConfirmation_IsCaseInsensitive_ThenWaitsThroughAckUntilFreshRoster() { @@ -509,6 +569,17 @@ public sealed class CharacterManagementUiControllerTests "Please Wait", "Entering World"); + private static void AssertDetachedAndUnbound(ImportedLayout screen) + { + Assert.Null(screen.Root.Parent); + Assert.Null(Assert.IsType(screen.FindElement( + CharacterManagementUiController.EnterElementId)).OnClick); + Assert.Null(Assert.IsType(screen.FindElement( + CharacterManagementUiController.DeleteElementId)).OnClick); + Assert.Null(Assert.IsType(screen.FindElement( + CharacterManagementUiController.RestoreElementId)).OnClick); + } + private static ImportedLayout BuildScreen(bool includePreview = false) { var root = new ElementInfo diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 76510dd9..8baca976 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -542,6 +542,110 @@ public sealed class RetailDialogFactoryTests Assert.True(factory.CloseDialog(later)); } + [Fact] + public void PriorityRequest_PreemptsOrdinaryRetryAndPreservesOrdinaryFifo() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + bool waitsAvailable = false; + using var factory = new RetailDialogFactory(root, type => + type == RetailDialogType.Wait && !waitsAvailable + ? null + : BuildDialogLayout(type)); + + uint failed = factory.MakeWait("failed ordinary"); + uint later = factory.MakeWait("later ordinary"); + uint priority = factory.MakeDialog( + Priority(RetailDialogData.Message("priority"))); + + Assert.Equal(1, factory.ActiveCount); + Assert.Equal(1, factory.RetryCount); + Assert.Equal(1, factory.PendingCount); + Assert.Equal("priority", MessageFromAnyDialog(root.Modal!)); + + Assert.True(factory.CloseDialog(priority)); + Assert.Equal(0, factory.ActiveCount); + Assert.Equal(1, factory.RetryCount); + Assert.Equal(1, factory.PendingCount); + + waitsAvailable = true; + factory.Tick(); + Assert.Equal("failed ordinary", MessageFromAnyDialog(root.Modal!)); + Assert.True(factory.CloseDialog(failed)); + Assert.Equal("later ordinary", MessageFromAnyDialog(root.Modal!)); + Assert.True(factory.CloseDialog(later)); + } + + [Fact] + public void FailedPriority_RetriesAheadOfRestoredActiveAndQueuedDialog() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + bool priorityAvailable = false; + using var factory = new RetailDialogFactory(root, type => + type == RetailDialogType.Message && !priorityAvailable + ? null + : BuildDialogLayout(type)); + + uint active = factory.MakeWait("active"); + uint queued = factory.MakeWait("queued"); + uint priority = factory.MakeDialog( + Priority(RetailDialogData.Message("priority"))); + + Assert.Equal(1, factory.ActiveCount); + Assert.Equal(1, factory.RetryCount); + Assert.Equal(1, factory.PendingCount); + Assert.Equal("active", MessageFromAnyDialog(root.Modal!)); + + priorityAvailable = true; + factory.Tick(); + + Assert.Equal(1, factory.ActiveCount); + Assert.Equal(0, factory.RetryCount); + Assert.Equal(2, factory.PendingCount); + Assert.Equal("priority", MessageFromAnyDialog(root.Modal!)); + + Assert.True(factory.CloseDialog(priority)); + Assert.Equal("active", MessageFromAnyDialog(root.Modal!)); + Assert.Equal(1, factory.PendingCount); + Assert.True(factory.CloseDialog(active)); + Assert.Equal("queued", MessageFromAnyDialog(root.Modal!)); + Assert.True(factory.CloseDialog(queued)); + } + + [Fact] + public void MultipleRetries_NewestPriorityFirstThenOlderPriorityThenOrdinaryFifo() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + bool available = false; + using var factory = new RetailDialogFactory(root, type => + !available ? null : BuildDialogLayout(type)); + + uint ordinary = factory.MakeWait("ordinary"); + uint later = factory.MakeWait("later"); + uint olderPriority = factory.MakeDialog( + Priority(RetailDialogData.Message("older priority"))); + uint newerPriority = factory.MakeDialog(Priority( + RetailDialogData.ConfirmationTextInput("newer priority"))); + + Assert.Equal(0, factory.ActiveCount); + Assert.Equal(3, factory.RetryCount); + Assert.Equal(1, factory.PendingCount); + + available = true; + factory.Tick(); + + Assert.Equal(1, factory.ActiveCount); + Assert.Equal(2, factory.RetryCount); + Assert.Equal("newer priority", MessageFromAnyDialog(root.Modal!)); + + Assert.True(factory.CloseDialog(newerPriority)); + Assert.Equal("older priority", MessageFromAnyDialog(root.Modal!)); + Assert.True(factory.CloseDialog(olderPriority)); + Assert.Equal("ordinary", MessageFromAnyDialog(root.Modal!)); + Assert.True(factory.CloseDialog(ordinary)); + Assert.Equal("later", MessageFromAnyDialog(root.Modal!)); + Assert.True(factory.CloseDialog(later)); + } + private static RetailDialogFactory CreateFactory( UiRoot root, List layouts) @@ -575,6 +679,11 @@ public sealed class RetailDialogFactoryTests .LinesProvider() .Select(static line => line.Text)); + private static RetailDialogData Priority(RetailDialogData data) => + data.Set(RetailDialogProperty.Priority, true) + .Set(RetailDialogProperty.QueueKey, + RetailDialogFactory.DefaultQueueKey); + internal static ImportedLayout BuildDialogLayout(RetailDialogType type) { uint rootId = RetailDialogFactory.RootElementId(type); From df824f366cbd52f83b85c14c90c4ab6ea1eeadf2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 21:18:05 +0200 Subject: [PATCH 047/138] docs(launcher): close Campaign LA6 LA8 and LA9 --- CLAUDE.md | 11 ++++++----- docs/plans/2026-04-11-roadmap.md | 11 ++++++----- docs/plans/2026-08-14-launcher-campaign.md | 6 +++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ba9033fe..9cf60ec2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,13 +245,14 @@ NO 3D preview (chargen-only machinery); UI Studio no longer exists (deleted at Campaign V — ignore stale memory/docs claims otherwise); App `Program.cs` has no subcommand dispatch (the `--session-config` flag is additive). -LA0 through LA5 plus LA7 are review-closed. The launcher composer is now +LA0 through LA9 are review-closed. The launcher composer is now compiled into both host test suites, and Launcher.Core runs in the portable Windows/Ubuntu CI closure. The self-contained Avalonia launcher, -transactional two-host plugin lifetime, and Runtime-owned retail selection -state/flow are integrated; the combined Release gate passes 13,769 tests / 4 -skips. LA6 login commands, LA8's authored retail screen, and LA9 installer are -the active parallel wave; LA10 updater and LA11 closeout follow. +transactional two-host plugin lifetime, shared login-command route, +Runtime-owned retail selection state, authored DAT character screen, and +crash-safe verified installer are integrated; the combined Release gate +passes 13,865 tests / 5 skips. LA10 updater is the final implementation slice; +LA11 closeout follows. **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 0c3c68eb..7033f339 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -103,14 +103,15 @@ a future campaign). Spec: [`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); plan + ledger: [`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). -LA0 through LA5 plus LA7 are review-closed: the portable path boundary, +LA0 through LA9 are review-closed: the portable path boundary, failure-isolated launch/status contract, BCL-only launcher core, shared composer-to-both-host-loader anti-drift gate, and character wire messages are landed. The self-contained Avalonia launcher, transactional two-host plugin -lifetime, and Runtime-owned retail selection state/flow are integrated. The -combined Release gate passes 13,769 tests / 4 skips. LA6 login commands, LA8's -authored retail screen, and LA9 installer are the active parallel wave; LA10 -updater and LA11 connected/visual closeout follow. +lifetime, shared login-command route, Runtime-owned retail selection state, +authored DAT character screen, and crash-safe verified installer are +integrated. The combined Release gate passes 13,865 tests / 5 skips. LA10 +updater is the final implementation slice; LA11 connected/visual closeout +follows. **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 853e69fc..56ab2c07 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -507,9 +507,9 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. | | LA4 | **DONE + MERGED 2026-08-14** | `d0a9c65d`, `10a712d6`, `ae2cbbee`, merge `60f62799` | Initial dual-lens review found 10 issues; fix re-review left one Linux execute-bit gap; final narrow re-review PASS | Avalonia 12.1.1 launcher remains thin over one BCL-only Core orchestrator. Windows/WSL Launcher.Core 162/162 and Launcher 17/17. Native `linux-x64` publish evaluates self-contained + single-file, runs without a discoverable runtime, and CI verifies executable launcher/App/Headless artifacts. LA9/LA10 bodies and LA11 visual/accessibility confirmation remain intentionally later. | | LA5 | **DONE + MERGED 2026-08-14** | `95f4be94`, `fbe9c8a2`, `f820eb25`, merge `5535d0ad` | Initial review found 5 issues; first narrow re-review found 4 ownership/race gaps; final narrow re-review PASS | Both hosts share exact absent/null=`all`, `[]`=`none` allow-listing; transactional scoped UI/entity/selection rollback precedes unload; graphical/headless status and teardown ordering match; headless replay is exact-once under Runtime's borrowed membership lease. Branch complete suite 13,679+4 skip; portable WSL closure green. | -| LA6 | — | | | | +| LA6 | **DONE + MERGED 2026-08-14** | `41b15efd`, `259f0e5a`, merge `2bb8ccb6` | Dual-lens/CH regression review found one Headless wire-parity gap; narrow re-review PASS | Runtime owns the sole parser/router/catalog and shared four-route live binding. Both hosts run generation-scoped login commands after world entry with strict monotonic delay and nonterminal v1 failure status. Headless permit/chat/notell semantics match App. Branch complete suite 13,787+4 skip; WSL Runtime 1,662, Headless 165, Launcher.Core 167, UI/chat 922. | | LA7 | **DONE + MERGED 2026-08-14** | LA7a `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4`; LA7b `0e82cbf7`, `1b9e7e41`, `ff406562`, merge `7691cf75` | LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed | Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; `NumErrors` is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green. | -| LA8 | — | | | | -| LA9 | — | | | | +| LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. | +| LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. | | LA10 | — | | | | | LA11 | — | | | | From 2d2a5b5046fb22c192b178c041e4db5ad9664fdd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 22:09:34 +0200 Subject: [PATCH 048/138] feat(launcher): implement verified atomic updates --- docs/architecture/acdream-architecture.md | 12 +- docs/plans/2026-08-14-launcher-campaign.md | 125 +++ .../2026-08-14-launcher-campaign-design.md | 12 + .../Orchestration/LauncherExecutableSet.cs | 106 +- .../Orchestration/LauncherOrchestrator.cs | 35 +- .../Updates/AtomicJsonFile.cs | 84 ++ .../Updates/ClientVersionStore.cs | 973 ++++++++++++++++++ .../Updates/LauncherRuntimeIdentity.cs | 33 + .../Updates/LauncherSelfUpdateBootstrap.cs | 299 ++++++ .../Updates/LauncherSelfUpdateManager.cs | 786 ++++++++++++++ .../Updates/LauncherUpdater.cs | 457 ++++++++ .../Updates/LauncherVersion.cs | 199 ++++ .../Updates/ReleaseManifest.cs | 37 + .../Updates/ReleaseManifestClient.cs | 300 ++++++ .../Updates/SafeZipExtractor.cs | 482 +++++++++ .../Updates/UpdateSessionBarrier.cs | 85 ++ .../Updates/VerifiedArtifactDownloader.cs | 200 ++++ src/AcDream.Launcher/App.axaml.cs | 51 +- src/AcDream.Launcher/MainWindow.axaml | 77 +- src/AcDream.Launcher/MainWindow.axaml.cs | 2 +- src/AcDream.Launcher/Program.cs | 31 +- .../ViewModels/LauncherUpdateViewModel.cs | 520 ++++++++++ .../ViewModels/LauncherWindowViewModel.cs | 43 +- .../Program.cs | 25 + .../LauncherOrchestratorTests.cs | 27 +- .../Updates/ClientVersionStoreTests.cs | 207 ++++ .../Updates/LauncherSelfUpdateManagerTests.cs | 279 +++++ .../LauncherUpdaterIntegrationTests.cs | 211 ++++ .../Updates/ReleaseTransportTests.cs | 219 ++++ .../Updates/SafeZipExtractorTests.cs | 186 ++++ .../Updates/UpdateSessionBarrierTests.cs | 147 +++ .../Updates/UpdateTestSupport.cs | 270 +++++ .../LauncherUpdateViewModelTests.cs | 292 ++++++ .../LauncherWindowViewModelTests.cs | 4 +- 34 files changed, 6755 insertions(+), 61 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs create mode 100644 src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs create mode 100644 src/AcDream.Launcher.Core/Updates/LauncherVersion.cs create mode 100644 src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs create mode 100644 src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs create mode 100644 src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs create mode 100644 src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs create mode 100644 src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs create mode 100644 src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/UpdateSessionBarrierTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/UpdateTestSupport.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2fcef60c..1d1f44de 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -329,11 +329,21 @@ src/ adjacent `..acdream-bake..tmp` files are transaction-owned crash residue + Updates/ -> pinned GitHub manifest + strict SemVer/RID + authority, bounded verified streaming download, + hardened ZIP extraction, immutable + `app//` installs, atomic `current.json` + activation/rollback, and durable next-start + launcher self-update journal; one OS-handle + shared-session/exclusive-update barrier spans + every launcher process -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell ViewModels/ -> thin MVVM projection over Launcher.Core, - including the first-run DAT/bake wizard + including the first-run DAT/bake wizard and + nonfatal startup/manual update state, actions, + progress, cancellation, rollback, and errors -> references Launcher.Core only (Platform transitively); it never owns a second profile, process, status, or credential state graph -> every per-RID publish composes the separately published self-contained diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 853e69fc..151d7c78 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -478,6 +478,131 @@ gate (user): clean-profile first-run against real DATs. fixture; rollback test; refusal-while-running test; self-update staging test; suites green. Connected gate (user): staged-manifest update swap end-to-end. +### Pinned updater contracts (v1, BINDING) + +This section is the single source of truth for every LA10 feed and on-disk +shape. Readers use strict, case-sensitive `System.Text.Json` parsing, reject +unknown or duplicate properties, and reject unsupported schema versions +before doing network, extraction, or activation work. + +The production feed is pinned to GitHub owner/repository +`eriknihlen/acdream`; the launcher reads +`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`. +Tests may inject a loopback HTTP URI, but production artifacts and redirects +must use HTTPS. `manifest.json` is: + +```json +{ + "schemaVersion": 1, + "version": "1.2.3", + "minimumLauncherVersion": "1.1.0", + "clients": { + "win-x64": { + "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip", + "sha256": "<64 hex characters>", + "size": 123 + } + }, + "launchers": { + "win-x64": { + "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip", + "sha256": "<64 hex characters>", + "size": 123 + } + } +} +``` + +`version` and `minimumLauncherVersion` are strict SemVer 2.0 strings. Build +metadata is ignored for precedence; numeric identifiers are compared without +fixed-width integer overflow. RID keys are exact lowercase portable RIDs. +Both dictionaries are required and the running RID must have a client and a +launcher row. Artifact sizes are positive and capped by the launcher's +download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute. +Client ZIPs have the two host executables at their root +(`AcDream.App[.exe]`, `acdream-headless[.exe]`); launcher ZIPs have +`acdream-launcher[.exe]` at their root. No implicit wrapper directory exists. + +Every extracted client version has +`DataDirectory/app//install.json`: + +```json +{ + "schemaVersion": 1, + "version": "1.2.3", + "rid": "win-x64", + "archiveSha256": "<64 hex characters>", + "archiveSize": 123, + "files": [ + { "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ] +} +``` + +Paths use `/`, are relative, normalized, unique under ordinal-ignore-case, +and sorted ordinally. `unixMode` contains only the portable permission bits +captured from the ZIP entry. Startup verifies every recorded regular file by +size/SHA, rejects unrecorded files/reparse points, and requires the two host +executables before admitting a version. Extraction uses a random sibling +directory under `DataDirectory/app/`; promotion to `/` is one +same-volume directory rename. + +`DataDirectory/app/current.json` is the only activation authority: + +```json +{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" } +``` + +`previousVersion` is omitted for the first activation. Pointer writes are +write-through temporary-file + same-directory atomic rename. The last valid +pointer is also atomically preserved as `current.previous.json`; startup may +restore that exact backup only when `current.json` is missing/malformed and +the referenced version verifies. Orphan LA10 staging directories and pointer +temporaries are transaction-owned by exact names and are removed under the +update lease. A corrupt installed version is never silently selected; the +explicit one-step rollback swaps the two verified pointer versions. + +`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each +supervised launcher activity holds a shared OS handle from before executable +resolution until terminal process observation; an update/rollback holds the +exclusive handle for its entire recovery/download/extract/promote/pointer +transaction. Failure to acquire the exclusive handle is an immediate refusal, +not a wait behind a running session. The open handle, not lock-file contents, +owns the lease and therefore releases after process death. + +Launcher self-update staging lives at +`DataDirectory/launcher-update/transactions//` and the sole +durable authority is `DataDirectory/launcher-update/pending.json`: + +```json +{ + "schemaVersion": 1, + "transactionId": "0123456789abcdef0123456789abcdef", + "state": "staged", + "version": "1.2.3", + "rid": "win-x64", + "targetDirectory": "", + "archiveSha256": "<64 hex characters>", + "archiveSize": 123, + "files": [ + { "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ], + "apply": null +} +``` + +Before mutation a next-start helper copied outside the target directory +atomically advances the plan to `applying` and fills `apply` with each path's +`hadOriginal` bit. It waits for the initiating launcher PID without invoking a +shell, moves originals into the transaction backup tree, then moves verified +staged files into place. It never opens a target with truncate/overwrite. On +success the plan becomes `awaitingConfirmation`; the new launcher confirms at +its first managed instruction, after which backup and plan cleanup is safe. An +`applying` plan is rolled back before retry, and failure to start/confirm the +new launcher restores every original (and removes every no-original target). +All plan paths are re-derived/contained under the pinned data root except the +target directory, which must equal the actual launcher base directory. + ## LA11 — closeout - One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md` diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index 954a65fe..6c4d8d07 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -303,6 +303,18 @@ preview would be a deliberate divergence we are NOT taking. - **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips are release assets; the launcher pins the repo/owner in its config. +The exact v1 manifest, extracted-version record, `current.json` activation +pointer, shared-session/exclusive-update OS lease, and durable self-update +plan are pinned in +`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater +contracts (v1, BINDING)**. That section is normative: implementations reject +unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 +precedence, verify bounded streamed downloads before safe ZIP extraction, and +derive all mutable staging/backup paths from the application data root. The +LA9 DAT/pak install record remains the sole content descriptor fed to session +configs; LA10 changes only which verified `app/current.json` client binaries +the process supervisor executes. + ## 10. Testing - **Launcher.Core unit tests** (new test project, registered in diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index ef4635c5..5fc2a858 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -1,18 +1,19 @@ using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; namespace AcDream.Launcher.Core.Orchestration; /// -/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will -/// replace the directory lookup with its versioned-current resolver; until -/// then a missing host disables the corresponding action instead of deferring -/// failure until process creation. +/// Resolves and validates the graphical/headless hosts. Production uses the +/// verified DataDirectory/app/current.json resolver; the explicit-path +/// constructor remains the injectable test seam. /// public sealed class LauncherExecutableSet { private readonly Func _fileExists; private readonly Func _hasUnixExecutePermission; + private readonly Func _resolve; public LauncherExecutableSet( string graphicalHostPath, @@ -23,25 +24,50 @@ public sealed class LauncherExecutableSet { ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath); ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath); - GraphicalHostPath = graphicalHostPath; - HeadlessHostPath = headlessHostPath; - WorkingDirectory = workingDirectory; + string graphical = graphicalHostPath; + string headless = headlessHostPath; + _resolve = () => new ExecutablePaths(graphical, headless, workingDirectory); _fileExists = fileExists ?? File.Exists; _hasUnixExecutePermission = hasUnixExecutePermission ?? HasUnixExecutePermission; } - public string GraphicalHostPath { get; } + private LauncherExecutableSet( + Func resolve, + Func? fileExists = null, + Func? hasUnixExecutePermission = null) + { + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _fileExists = fileExists ?? File.Exists; + _hasUnixExecutePermission = + hasUnixExecutePermission ?? HasUnixExecutePermission; + } - public string HeadlessHostPath { get; } + public string GraphicalHostPath => _resolve().GraphicalHostPath; - public string? WorkingDirectory { get; } + public string HeadlessHostPath => _resolve().HeadlessHostPath; + + public string? WorkingDirectory => _resolve().WorkingDirectory; public LauncherCapability GetAvailability(LaunchMode mode) { + ExecutablePaths paths; + try + { + paths = _resolve(); + } + catch (Exception ex) when (ex is LauncherUpdateException + or InvalidOperationException + or IOException + or UnauthorizedAccessException) + { + return LauncherCapability.Unavailable( + $"The active versioned client is unavailable: {ex.Message}"); + } + string path = mode == LaunchMode.Headless - ? HeadlessHostPath - : GraphicalHostPath; + ? paths.HeadlessHostPath + : paths.GraphicalHostPath; string host = mode == LaunchMode.Headless ? "headless host" : "graphical client"; @@ -68,27 +94,27 @@ public sealed class LauncherExecutableSet string configFilePath) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); - RequireAvailable(mode); + ExecutablePaths paths = RequireAvailable(mode); return mode == LaunchMode.Headless ? new LauncherProcessSpec( - HeadlessHostPath, + paths.HeadlessHostPath, ["--config", configFilePath], - WorkingDirectory) + paths.WorkingDirectory) : new LauncherProcessSpec( - GraphicalHostPath, + paths.GraphicalHostPath, ["--session-config", configFilePath], - WorkingDirectory); + paths.WorkingDirectory); } public LauncherProcessSpec CreateProbeSpec(string configFilePath) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); - RequireAvailable(LaunchMode.Headless); + ExecutablePaths paths = RequireAvailable(LaunchMode.Headless); return new LauncherProcessSpec( - HeadlessHostPath, + paths.HeadlessHostPath, ["--config", configFilePath], - WorkingDirectory); + paths.WorkingDirectory); } public static LauncherExecutableSet FromDirectory(string directory) @@ -102,7 +128,28 @@ public sealed class LauncherExecutableSet fullDirectory); } - private void RequireAvailable(LaunchMode mode) + /// + /// Dynamic production resolver. The store cache is admitted only after a + /// strict startup/update verification, and a pointer swap changes the + /// binaries selected for the next session without replacing LA9 content. + /// + public static LauncherExecutableSet FromCurrentVersionStore( + ClientVersionStore store) + { + ArgumentNullException.ThrowIfNull(store); + return new LauncherExecutableSet(() => + { + ClientVersionResolution resolution = store.CachedResolution; + if (!resolution.IsVerified || resolution.Directory is null) + { + throw new LauncherUpdateException(resolution.Status); + } + + return FromDirectoryPaths(resolution.Directory); + }); + } + + private ExecutablePaths RequireAvailable(LaunchMode mode) { LauncherCapability capability = GetAvailability(mode); if (!capability.IsAvailable) @@ -110,6 +157,18 @@ public sealed class LauncherExecutableSet throw new LauncherOperationException( capability.Reason ?? "The selected launcher host is unavailable."); } + + return _resolve(); + } + + private static ExecutablePaths FromDirectoryPaths(string directory) + { + string fullDirectory = Path.GetFullPath(directory); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return new ExecutablePaths( + Path.Combine(fullDirectory, "AcDream.App" + executableSuffix), + Path.Combine(fullDirectory, "acdream-headless" + executableSuffix), + fullDirectory); } private static bool HasUnixExecutePermission(string path) @@ -134,4 +193,9 @@ public sealed class LauncherExecutableSet return false; } } + + private sealed record ExecutablePaths( + string GraphicalHostPath, + string HeadlessHostPath, + string? WorkingDirectory); } diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index c9a7a582..d430eaad 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -1,6 +1,7 @@ using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Profiles; using AcDream.Launcher.Core.Status; +using AcDream.Launcher.Core.Updates; using AcDream.Platform; namespace AcDream.Launcher.Core.Orchestration; @@ -26,6 +27,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator private readonly ILauncherProcessSupervisorFactory _supervisorFactory; private readonly IStatusEventSourceFactory _statusSourceFactory; private readonly Func _sessionIdFactory; + private readonly UpdateSessionBarrier _updateSessionBarrier; private readonly List _activities = []; private LauncherInstallRecord? _installRecord; @@ -42,7 +44,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ILauncherProcessSupervisorFactory? supervisorFactory = null, IStatusEventSourceFactory? statusSourceFactory = null, Func? sessionIdFactory = null, - string? installationStatus = null) + string? installationStatus = null, + UpdateSessionBarrier? updateSessionBarrier = null) { _profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore)); _paths = paths ?? throw new ArgumentNullException(nameof(paths)); @@ -53,6 +56,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator _supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory(); _statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory(); _sessionIdFactory = sessionIdFactory ?? CreateSessionId; + _updateSessionBarrier = updateSessionBarrier + ?? new UpdateSessionBarrier(paths.DataDirectory); _installationStatus = installationStatus ?? (installRecord is null ? FirstRunRequired @@ -629,10 +634,18 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { ILauncherProcessSupervisor? supervisor = null; string? password = request.Password; + bool hostStarted = false; try { request.Cancellation.Token.ThrowIfCancellationRequested(); + UpdateSessionBarrier.SessionLease sessionLease = + _updateSessionBarrier.AcquireSession(); + lock (_gate) + { + request.Activity.UpdateSessionLease = sessionLease; + } + ComposedSessionConfig composed = request.IsProbe ? _configService.ComposeProbeAndWrite( request.Server, @@ -674,6 +687,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator request.Activity.LaunchMode!.Value, composed.ConfigFilePath); supervisor.Start(processSpec, password); + hostStarted = true; request.Password = null; password = null; @@ -728,6 +742,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator finally { request.Password = null; + if (!hostStarted) + { + ReleaseUpdateSessionLease(request.Activity); + } } } @@ -735,6 +753,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ManagedActivity activity, LauncherSessionState processState) { + UpdateSessionBarrier.SessionLease? sessionLease = null; try { lock (_gate) @@ -774,10 +793,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { activity.Status = activity.HostTerminalStatus; } + sessionLease = activity.UpdateSessionLease; + activity.UpdateSessionLease = null; break; } } + sessionLease?.Dispose(); RaiseStateChanged(); } catch @@ -1192,6 +1214,15 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator activity.Supervisor.Dispose(); activity.Supervisor = null; } + + ReleaseUpdateSessionLease(activity); + } + + private static void ReleaseUpdateSessionLease(ManagedActivity activity) + { + UpdateSessionBarrier.SessionLease? lease = + Interlocked.Exchange(ref activity.UpdateSessionLease, null); + lease?.Dispose(); } private void ThrowIfDisposed() @@ -1252,6 +1283,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public CancellationTokenSource? StartCancellation { get; set; } + public UpdateSessionBarrier.SessionLease? UpdateSessionLease; + public object StatusReadGate { get; } = new(); public bool IsActive => State is not ( diff --git a/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs b/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs new file mode 100644 index 00000000..919bd737 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace AcDream.Launcher.Core.Updates; + +internal static class AtomicJsonFile +{ + internal static async Task WriteAsync( + string path, + T value, + JsonSerializerOptions options, + CancellationToken cancellationToken = default) + { + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("The JSON path has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync( + stream, + value, + options, + cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, fullPath, overwrite: true); + } + finally + { + VerifiedArtifactDownloader.TryDelete(temporaryPath); + } + } + + internal static async Task WriteBytesAsync( + string path, + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default) + { + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("The file path has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, fullPath, overwrite: true); + } + finally + { + VerifiedArtifactDownloader.TryDelete(temporaryPath); + } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs new file mode 100644 index 00000000..e14163b0 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs @@ -0,0 +1,973 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Integrity; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record InstalledFileRecord( + string Path, + string Sha256, + long Size, + int UnixMode); + +public sealed record ClientVersionRecord( + int SchemaVersion, + string Version, + string Rid, + string ArchiveSha256, + long ArchiveSize, + IReadOnlyList Files) +{ + public const int CurrentSchemaVersion = 1; +} + +public sealed record ClientActivationPointer( + int SchemaVersion, + string CurrentVersion, + string? PreviousVersion) +{ + public const int CurrentSchemaVersion = 1; +} + +public enum ClientVersionState +{ + Missing, + Verified, + Invalid, +} + +public sealed record ClientVersionResolution( + ClientVersionState State, + string Status, + LauncherVersion? Version, + string? Directory, + string? PreviousVersion, + ClientVersionRecord? Record) +{ + public bool IsVerified => State == ClientVersionState.Verified; +} + +/// +/// Strict installed-version and activation-pointer authority. LA9's DAT/pak +/// record is intentionally not represented here. +/// +public sealed class ClientVersionStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + MaxDepth = 32, + }; + + private readonly object _gate = new(); + private readonly Func> _computeSha256; + private ClientVersionResolution _cached = new( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null); + + public ClientVersionStore( + ApplicationPathSet paths, + Func>? computeSha256 = null) + { + ArgumentNullException.ThrowIfNull(paths); + AppDirectory = Path.Combine(Path.GetFullPath(paths.DataDirectory), "app"); + CurrentPointerPath = Path.Combine(AppDirectory, "current.json"); + PreviousPointerPath = Path.Combine(AppDirectory, "current.previous.json"); + Barrier = new UpdateSessionBarrier(paths.DataDirectory); + _computeSha256 = computeSha256 + ?? ((path, token) => FileIntegrity.ComputeSha256HexAsync(path, token)); + } + + public string AppDirectory { get; } + + public string CurrentPointerPath { get; } + + public string PreviousPointerPath { get; } + + public UpdateSessionBarrier Barrier { get; } + + public ClientVersionResolution CachedResolution + { + get + { + lock (_gate) + { + return _cached; + } + } + } + + public string GetVersionDirectory(LauncherVersion version) => + Path.Combine(AppDirectory, version.Value); + + public static string GetMetadataPath(string versionDirectory) => + Path.Combine(Path.GetFullPath(versionDirectory), "install.json"); + + public async Task LoadAndRecoverAsync( + string rid, + CancellationToken cancellationToken = default) + { + try + { + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); + return await LoadAndRecoverUnderLeaseAsync(rid, cancellationToken) + .ConfigureAwait(false); + } + catch (LauncherUpdateException ex) when (ex.InnerException is IOException) + { + // Another launcher may legitimately hold a shared session lease. + // Pointer publication is atomic and old versions are retained, so + // a read-only verification remains safe; mutation/recovery waits + // for the next startup without active sessions. + return await LoadCurrentReadOnlyAsync(rid, cancellationToken) + .ConfigureAwait(false); + } + } + + public async Task LoadCurrentReadOnlyAsync( + string rid, + CancellationToken cancellationToken = default) + { + RequireRid(rid); + PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + ClientVersionResolution resolution = current.Pointer is null + ? (!File.Exists(CurrentPointerPath) + ? new ClientVersionResolution( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null) + : Invalid(current.Error ?? "The client activation pointer is invalid.")) + : await ResolvePointerAsync(current.Pointer, rid, cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + internal async Task LoadAndRecoverUnderLeaseAsync( + string rid, + CancellationToken cancellationToken = default) + { + RequireRid(rid); + Directory.CreateDirectory(AppDirectory); + CleanupOwnedResidue(); + + PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + if (current.Pointer is not null) + { + ClientVersionResolution resolution = await ResolvePointerAsync( + current.Pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + PointerRead previous = await ReadPointerAsync(PreviousPointerPath, cancellationToken) + .ConfigureAwait(false); + if (previous.Pointer is not null) + { + ClientVersionResolution recovered = await ResolvePointerAsync( + previous.Pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + if (recovered.IsVerified) + { + await WritePointerFileAsync( + CurrentPointerPath, + previous.Pointer, + cancellationToken) + .ConfigureAwait(false); + recovered = recovered with + { + Status = "Recovered the last valid client activation pointer.", + }; + SetCached(recovered); + return recovered; + } + } + + ClientVersionResolution missingOrInvalid = + !File.Exists(CurrentPointerPath) && !File.Exists(PreviousPointerPath) + ? new ClientVersionResolution( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null) + : new ClientVersionResolution( + ClientVersionState.Invalid, + current.Error + ?? previous.Error + ?? "No valid client activation pointer could be recovered.", + null, + null, + null, + null); + SetCached(missingOrInvalid); + return missingOrInvalid; + } + + internal async Task PromoteAndActivateUnderLeaseAsync( + string stagingDirectory, + LauncherVersion version, + string rid, + ReleaseArtifact artifact, + IReadOnlyList extractedFiles, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingDirectory); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(artifact); + ArgumentNullException.ThrowIfNull(extractedFiles); + RequireRid(rid); + + string staging = Path.GetFullPath(stagingDirectory); + RequireOwnedStagingPath(staging); + ValidateRequiredExecutables(extractedFiles, rid, launcherPayload: false); + if (extractedFiles.Any(file => string.Equals( + file.Path, + "install.json", + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + "The client ZIP may not provide the launcher's install.json record."); + } + + var record = new ClientVersionRecord( + ClientVersionRecord.CurrentSchemaVersion, + version.Value, + rid, + artifact.Sha256.ToLowerInvariant(), + artifact.Size, + extractedFiles + .Select(file => new InstalledFileRecord( + file.Path, + file.Sha256, + file.Size, + file.UnixMode)) + .OrderBy(file => file.Path, StringComparer.Ordinal) + .ToArray()); + ValidateRecord(record, version, rid); + await AtomicJsonFile.WriteAsync( + GetMetadataPath(staging), + record, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + ClientVersionResolution staged = await VerifyVersionDirectoryAsync( + staging, + version, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!staged.IsVerified) + { + throw new LauncherUpdateException(staged.Status); + } + + ClientActivationPointer? oldPointer = (await ReadPointerAsync( + CurrentPointerPath, + cancellationToken) + .ConfigureAwait(false)).Pointer; + string target = GetVersionDirectory(version); + if (Directory.Exists(target)) + { + ClientVersionResolution existing = await VerifyVersionDirectoryAsync( + target, + version, + rid, + cancellationToken) + .ConfigureAwait(false); + if (existing.IsVerified + && existing.Record is not null + && string.Equals( + existing.Record.ArchiveSha256, + artifact.Sha256, + StringComparison.OrdinalIgnoreCase) + && existing.Record.ArchiveSize == artifact.Size) + { + SafeZipExtractor.TryDeleteDirectory(staging); + } + else + { + if (oldPointer is not null + && string.Equals( + oldPointer.CurrentVersion, + version.Value, + StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + "The active client version is corrupt and cannot be replaced in place. " + + "Roll back before repairing it."); + } + + string quarantine = Path.Combine( + AppDirectory, + $".client-corrupt-{Guid.NewGuid():N}"); + Directory.Move(target, quarantine); + try + { + Directory.Move(staging, target); + } + catch + { + Directory.Move(quarantine, target); + throw; + } + + SafeZipExtractor.TryDeleteDirectory(quarantine); + } + } + else + { + Directory.Move(staging, target); + } + + string? previousVersion = oldPointer is null + || string.Equals( + oldPointer.CurrentVersion, + version.Value, + StringComparison.Ordinal) + ? oldPointer?.PreviousVersion + : oldPointer.CurrentVersion; + var pointer = new ClientActivationPointer( + ClientActivationPointer.CurrentSchemaVersion, + version.Value, + previousVersion); + await SavePointerAsync(pointer, cancellationToken).ConfigureAwait(false); + ClientVersionResolution resolution = await ResolvePointerAsync( + pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!resolution.IsVerified) + { + throw new LauncherUpdateException(resolution.Status); + } + + SetCached(resolution); + return resolution; + } + + public async Task RollbackAsync( + string rid, + CancellationToken cancellationToken = default) + { + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); + PointerRead read = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + ClientActivationPointer pointer = read.Pointer + ?? throw new LauncherUpdateException( + read.Error ?? "There is no active client version to roll back."); + if (string.IsNullOrEmpty(pointer.PreviousVersion)) + { + throw new LauncherUpdateException( + "There is no previous client version available for rollback."); + } + + LauncherVersion previous = LauncherVersion.Parse(pointer.PreviousVersion); + ClientVersionResolution verified = await VerifyVersionDirectoryAsync( + GetVersionDirectory(previous), + previous, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!verified.IsVerified) + { + throw new LauncherUpdateException( + $"The previous client version cannot be activated: {verified.Status}"); + } + + var swapped = new ClientActivationPointer( + ClientActivationPointer.CurrentSchemaVersion, + previous.Value, + pointer.CurrentVersion); + await SavePointerAsync(swapped, cancellationToken).ConfigureAwait(false); + ClientVersionResolution resolution = await ResolvePointerAsync( + swapped, + rid, + cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + internal string CreateClientStagingDirectory(Guid transactionId) + { + Directory.CreateDirectory(AppDirectory); + return Path.Combine(AppDirectory, $".client-staging-{transactionId:N}"); + } + + internal static void ValidateRequiredExecutables( + IReadOnlyList files, + string rid, + bool launcherPayload) + { + string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; + string[] required = launcherPayload + ? ["acdream-launcher" + suffix] + : ["AcDream.App" + suffix, "acdream-headless" + suffix]; + foreach (string path in required) + { + ExtractedFileRecord? file = files.SingleOrDefault(candidate => + string.Equals(candidate.Path, path, StringComparison.Ordinal)); + if (file is null) + { + throw new LauncherUpdateException( + $"The release ZIP is missing required root executable '{path}'."); + } + + if (rid.StartsWith("linux-", StringComparison.Ordinal) + && (file.UnixMode & (int)UnixFileMode.UserExecute) == 0) + { + throw new LauncherUpdateException( + $"The Linux release executable '{path}' lacks owner execute permission."); + } + } + } + + private async Task ResolvePointerAsync( + ClientActivationPointer pointer, + string rid, + CancellationToken cancellationToken) + { + string? error = ValidatePointer(pointer); + if (error is not null) + { + return Invalid(error); + } + + LauncherVersion version = LauncherVersion.Parse(pointer.CurrentVersion); + ClientVersionResolution resolution = await VerifyVersionDirectoryAsync( + GetVersionDirectory(version), + version, + rid, + cancellationToken) + .ConfigureAwait(false); + return resolution.IsVerified + ? resolution with { PreviousVersion = pointer.PreviousVersion } + : resolution; + } + + private async Task VerifyVersionDirectoryAsync( + string directory, + LauncherVersion version, + string rid, + CancellationToken cancellationToken) + { + if (!Directory.Exists(directory)) + { + return Invalid($"Client version {version} directory is missing."); + } + + try + { + RejectReparseTree(directory); + ClientVersionRecord? record = await ReadStrictAsync( + GetMetadataPath(directory), + cancellationToken) + .ConfigureAwait(false); + if (record is null) + { + return Invalid($"Client version {version} install.json is missing."); + } + + string? contractError = ValidateRecord(record, version, rid); + if (contractError is not null) + { + return Invalid(contractError); + } + + string[] actualFiles = Directory.EnumerateFiles( + directory, + "*", + SearchOption.AllDirectories) + .Select(path => NormalizeRelative(directory, path)) + .Where(path => !string.Equals( + path, + "install.json", + StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] recordedFiles = record.Files + .Select(file => file.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actualFiles.SequenceEqual(recordedFiles, StringComparer.Ordinal)) + { + return Invalid( + $"Client version {version} contains missing or unrecorded files."); + } + + foreach (InstalledFileRecord file in record.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ResolveContained(directory, file.Path); + var info = new FileInfo(path); + if (!info.Exists || info.Length != file.Size) + { + return Invalid( + $"Client version {version} file '{file.Path}' size is corrupt."); + } + + string sha256 = await _computeSha256(path, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + sha256, + file.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + return Invalid( + $"Client version {version} file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + return Invalid( + $"Client version {version} file '{file.Path}' mode is corrupt."); + } + } + + return new ClientVersionResolution( + ClientVersionState.Verified, + $"Client version {version} verified.", + version, + directory, + null, + record); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or FormatException + or LauncherUpdateException) + { + return Invalid( + $"Client version {version} could not be verified: {ex.Message}"); + } + } + + private async Task SavePointerAsync( + ClientActivationPointer pointer, + CancellationToken cancellationToken) + { + string? error = ValidatePointer(pointer); + if (error is not null) + { + throw new LauncherUpdateException(error); + } + + if (File.Exists(CurrentPointerPath)) + { + byte[] previous = await File.ReadAllBytesAsync( + CurrentPointerPath, + cancellationToken) + .ConfigureAwait(false); + PointerRead validPrevious = ParsePointer(previous); + if (validPrevious.Pointer is not null) + { + await AtomicJsonFile.WriteBytesAsync( + PreviousPointerPath, + previous, + cancellationToken) + .ConfigureAwait(false); + } + } + + await WritePointerFileAsync(CurrentPointerPath, pointer, cancellationToken) + .ConfigureAwait(false); + } + + private static Task WritePointerFileAsync( + string path, + ClientActivationPointer pointer, + CancellationToken cancellationToken) => + AtomicJsonFile.WriteAsync(path, pointer, SerializerOptions, cancellationToken); + + private static async Task ReadPointerAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return new PointerRead(null, null); + } + + try + { + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + return ParsePointer(bytes); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new PointerRead(null, $"Client pointer could not be read: {ex.Message}"); + } + } + + private static PointerRead ParsePointer(ReadOnlyMemory bytes) + { + try + { + ClientActivationPointer? pointer = ParseStrict(bytes.Span); + string? error = pointer is null + ? "Client pointer is empty." + : ValidatePointer(pointer); + return error is null + ? new PointerRead(pointer, null) + : new PointerRead(null, error); + } + catch (Exception ex) when (ex is JsonException + or LauncherUpdateException + or FormatException) + { + return new PointerRead(null, $"Client pointer is invalid: {ex.Message}"); + } + } + + private static string? ValidatePointer(ClientActivationPointer pointer) + { + if (pointer.SchemaVersion != ClientActivationPointer.CurrentSchemaVersion) + { + return $"Client pointer schema version {pointer.SchemaVersion} is not supported."; + } + + if (!LauncherVersion.TryParse(pointer.CurrentVersion, out _)) + { + return "Client pointer currentVersion is invalid."; + } + + if (pointer.PreviousVersion is not null + && (!LauncherVersion.TryParse(pointer.PreviousVersion, out _) + || string.Equals( + pointer.PreviousVersion, + pointer.CurrentVersion, + StringComparison.Ordinal))) + { + return "Client pointer previousVersion is invalid."; + } + + return null; + } + + private static string? ValidateRecord( + ClientVersionRecord record, + LauncherVersion version, + string rid) + { + if (record.SchemaVersion != ClientVersionRecord.CurrentSchemaVersion) + { + return $"Client install schema version {record.SchemaVersion} is not supported."; + } + + if (!string.Equals(record.Version, version.Value, StringComparison.Ordinal) + || !LauncherVersion.TryParse(record.Version, out _)) + { + return "Client install version does not match its directory."; + } + + if (!string.Equals(record.Rid, rid, StringComparison.Ordinal) + || !LauncherRuntimeIdentity.IsValidRid(record.Rid)) + { + return $"Client install RID does not match '{rid}'."; + } + + if (!ReleaseManifestClient.IsSha256(record.ArchiveSha256) + || record.ArchiveSize <= 0 + || record.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes) + { + return "Client install archive metadata is invalid."; + } + + if (record.Files is null || record.Files.Count == 0) + { + return "Client install file list is empty."; + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in record.Files) + { + if (!IsNormalizedRelative(file.Path) + || !paths.Add(file.Path) + || !ReleaseManifestClient.IsSha256(file.Sha256) + || file.Size < 0 + || file.UnixMode is < 0 or > 0x1FF + || (prior is not null + && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) + { + return "Client install file metadata is invalid, duplicated, or unsorted."; + } + + prior = file.Path; + } + + string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; + foreach (string required in new[] + { + "AcDream.App" + suffix, + "acdream-headless" + suffix, + }) + { + if (!paths.Contains(required)) + { + return $"Client install is missing '{required}'."; + } + } + + return null; + } + + private static async Task ReadStrictAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return default; + } + + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + return ParseStrict(bytes); + } + + internal static T? ParseStrict( + ReadOnlySpan bytes, + JsonSerializerOptions? serializerOptions = null) + { + using JsonDocument document = JsonDocument.Parse( + bytes.ToArray(), + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + RejectDuplicateProperties(document.RootElement, "$" ); + return document.RootElement.Deserialize( + serializerOptions ?? SerializerOptions); + } + + private static void RejectDuplicateProperties(JsonElement element, string path) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new LauncherUpdateException( + $"Duplicate JSON property '{path}.{property.Name}' is not allowed."); + } + + RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{path}[{index++}]"); + } + } + } + + private void CleanupOwnedResidue() + { + foreach (string path in Directory.EnumerateDirectories( + AppDirectory, + ".client-staging-*", + SearchOption.TopDirectoryOnly)) + { + string suffix = Path.GetFileName(path)[".client-staging-".Length..]; + if (Guid.TryParseExact(suffix, "N", out _)) + { + SafeZipExtractor.TryDeleteDirectory(path); + } + } + + foreach (string path in Directory.EnumerateFiles( + AppDirectory, + ".current*.tmp", + SearchOption.TopDirectoryOnly)) + { + string fileName = Path.GetFileName(path); + string[] parts = fileName.Split('.'); + if (parts.Length >= 4 + && string.Equals(parts[^1], "tmp", StringComparison.Ordinal) + && Guid.TryParseExact(parts[^2], "N", out _)) + { + VerifiedArtifactDownloader.TryDelete(path); + } + } + } + + private void RequireOwnedStagingPath(string path) + { + string parent = Path.GetDirectoryName(path) ?? string.Empty; + string fileName = Path.GetFileName(path); + if (!PathsEqual(parent, AppDirectory) + || !fileName.StartsWith(".client-staging-", StringComparison.Ordinal) + || !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _)) + { + throw new LauncherUpdateException( + "The client extraction path is not an owned LA10 staging directory."); + } + } + + internal static void RejectReparseTree(string root) + { + if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException("The client version directory is a reparse point."); + } + + var pending = new Stack(); + pending.Push(root); + while (pending.TryPop(out string? directory)) + { + foreach (string path in Directory.EnumerateFileSystemEntries( + directory, + "*", + SearchOption.TopDirectoryOnly)) + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Client install path '{NormalizeRelative(root, path)}' is a reparse point."); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + pending.Push(path); + } + } + } + } + + private static string NormalizeRelative(string root, string path) => + Path.GetRelativePath(root, path).Replace('\\', '/'); + + internal static bool IsNormalizedRelative(string? path) + { + if (string.IsNullOrEmpty(path) + || path.Length > 512 + || path.IndexOf('\0') >= 0 + || path.Contains('\\', StringComparison.Ordinal) + || path.Contains(':', StringComparison.Ordinal) + || path.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(path)) + { + return false; + } + + string[] parts = path.Split('/'); + return parts.All(part => + part.Length > 0 + && part is not ("." or "..") + && !part.EndsWith(' ') + && !part.EndsWith('.') + && !part.Any(character => + char.IsControl(character) + || character is '<' or '>' or '"' or '|' or '?' or '*') + && !IsWindowsDeviceName(part)); + } + + internal static string ResolveContained(string root, string relative) + { + if (!IsNormalizedRelative(relative)) + { + throw new LauncherUpdateException($"Unsafe relative path '{relative}'."); + } + + string fullRoot = Path.GetFullPath(root); + string path = Path.GetFullPath( + Path.Combine(fullRoot, relative.Replace('/', Path.DirectorySeparatorChar))); + string prefix = Path.EndsInDirectorySeparator(fullRoot) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + if (!path.StartsWith( + prefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + throw new LauncherUpdateException($"Path '{relative}' escaped its root."); + } + + return path; + } + + private static bool IsWindowsDeviceName(string segment) + { + string stem = segment.Split('.')[0]; + return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || (stem.Length == 4 + && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) + && stem[3] is >= '1' and <= '9'); + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static void RequireRid(string rid) + { + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + } + + private void SetCached(ClientVersionResolution resolution) + { + lock (_gate) + { + _cached = resolution; + } + } + + private static ClientVersionResolution Invalid(string status) => + new(ClientVersionState.Invalid, status, null, null, null, null); + + private sealed record PointerRead(ClientActivationPointer? Pointer, string? Error); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs b/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs new file mode 100644 index 00000000..7bf37410 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; + +namespace AcDream.Launcher.Core.Updates; + +public static class LauncherRuntimeIdentity +{ + public static string DetectRid() + { + string os = OperatingSystem.IsWindows() + ? "win" + : OperatingSystem.IsLinux() + ? "linux" + : throw new PlatformNotSupportedException( + "The launcher updater supports Windows and Linux only."); + string architecture = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException( + $"The launcher updater does not support {RuntimeInformation.ProcessArchitecture}."), + }; + return $"{os}-{architecture}"; + } + + internal static bool IsValidRid(string? rid) => + !string.IsNullOrEmpty(rid) + && rid.Length <= 64 + && rid[0] is >= 'a' and <= 'z' + && rid.All(character => + character is >= 'a' and <= 'z' + or >= '0' and <= '9' + or '-'); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs new file mode 100644 index 00000000..6470244a --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -0,0 +1,299 @@ +using System.Diagnostics; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record SelfUpdateStartupResult( + bool ShouldExit, + int ExitCode, + string[] RemainingArguments); + +/// +/// Process-level rename dance for launcher self-update. Every child argument +/// is passed through with +/// UseShellExecute=false; no path or PID is ever interpolated into a +/// shell command. +/// +public static class LauncherSelfUpdateBootstrap +{ + public const string HelperArgument = "--acdream-self-update-helper-v1"; + public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; + private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); + + public static async Task HandleAsync( + string[] args, + LauncherSelfUpdateManager manager, + string launcherBaseDirectory, + string currentExecutablePath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(manager); + string baseDirectory = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(launcherBaseDirectory)); + string executable = Path.GetFullPath(currentExecutablePath); + + if (args.Length > 0 + && string.Equals(args[0], HelperArgument, StringComparison.Ordinal)) + { + if (args.Length != 4 + || !int.TryParse( + args[1], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out int parentPid) + || parentPid <= 0) + { + return new SelfUpdateStartupResult(true, 64, []); + } + + int exitCode = await RunHelperAsync( + manager, + parentPid, + args[2], + args[3], + cancellationToken) + .ConfigureAwait(false); + return new SelfUpdateStartupResult(true, exitCode, []); + } + + if (args.Length > 0 + && string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal)) + { + if (args.Length != 2) + { + return new SelfUpdateStartupResult(true, 64, []); + } + + await manager.ConfirmAsync( + args[1], + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + return new SelfUpdateStartupResult(false, 0, []); + } + + SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (plan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } + + if (!PathsEqual(plan.TargetDirectory, baseDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update targets a different launcher directory."); + } + + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + if (!manager.IsConfirmed(plan.TransactionId)) + { + await manager.ConfirmAsync( + plan.TransactionId, + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + plan.TransactionId, + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + return new SelfUpdateStartupResult(false, 0, args); + } + + string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + string expectedExecutable = ClientVersionStore.ResolveContained( + baseDirectory, + "acdream-launcher" + suffix); + if (!PathsEqual(executable, expectedExecutable)) + { + throw new LauncherUpdateException( + "Self-update can start only from the published acdream-launcher executable."); + } + + string helperPath = manager.GetHelperPath(plan.TransactionId); + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + VerifiedArtifactDownloader.TryDelete(helperPath); + File.Copy(executable, helperPath, overwrite: false); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode( + helperPath, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute); + } + + var startInfo = new ProcessStartInfo(helperPath) + { + UseShellExecute = false, + WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId), + }; + startInfo.ArgumentList.Add(HelperArgument); + startInfo.ArgumentList.Add( + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(baseDirectory); + startInfo.ArgumentList.Add(plan.TransactionId); + _ = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The launcher self-update helper could not be started."); + return new SelfUpdateStartupResult(true, 0, []); + } + + private static async Task RunHelperAsync( + LauncherSelfUpdateManager manager, + int parentPid, + string targetDirectory, + string transactionId, + CancellationToken cancellationToken) + { + SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("The helper found no pending self-update."); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + "The helper transaction does not match the pending self-update."); + } + + string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + string launcherPath = ClientVersionStore.ResolveContained( + targetDirectory, + "acdream-launcher" + suffix); + var startInfo = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + startInfo.ArgumentList.Add(ConfirmArgument); + startInfo.ArgumentList.Add(transactionId); + + Process? replacement = null; + UpdateSessionBarrier.ExclusiveLease? updateLease = null; + bool appliedByThisHelper = false; + try + { + await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); + updateLease = manager.Barrier.AcquireExclusive(); + plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) + .ConfigureAwait(false); + appliedByThisHelper = true; + replacement = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The updated launcher could not be started."); + DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout; + while (!manager.IsConfirmed(transactionId)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline) + { + throw new LauncherUpdateException( + replacement.HasExited + ? $"The updated launcher exited with code {replacement.ExitCode} " + + "before confirming startup." + : "The updated launcher did not confirm startup in time."); + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + transactionId, + targetDirectory, + cancellationToken) + .ConfigureAwait(false); + return 0; + } + catch + { + if (replacement is { HasExited: false }) + { + replacement.Kill(entireProcessTree: true); + await replacement.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); + } + + try + { + if (appliedByThisHelper) + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + if (pending?.State == SelfUpdatePlanState.Applying) + { + _ = await manager.RecoverApplyingAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) + { + _ = await manager.RollbackAwaitingConfirmationAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + } + } + catch + { + // Do not start an executable from an ambiguous half-applied + // state. A subsequent startup replays the durable journal. + return 75; + } + + var restored = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + _ = Process.Start(restored); + return 74; + } + finally + { + replacement?.Dispose(); + updateLease?.Dispose(); + } + } + + private static async Task WaitForParentExitAsync( + int parentPid, + CancellationToken cancellationToken) + { + try + { + using Process parent = Process.GetProcessById(parentPid); + if (parent.Id == Environment.ProcessId) + { + throw new LauncherUpdateException( + "The self-update helper cannot wait on itself."); + } + + await parent.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException) + { + // The parent exited before the helper opened it. + } + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs new file mode 100644 index 00000000..1dddc952 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -0,0 +1,786 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Updates; + +public enum SelfUpdatePlanState +{ + Staged, + Applying, + AwaitingConfirmation, +} + +public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal); + +public sealed record SelfUpdatePlan( + int SchemaVersion, + string TransactionId, + SelfUpdatePlanState State, + string Version, + string Rid, + string TargetDirectory, + string ArchiveSha256, + long ArchiveSize, + IReadOnlyList Files, + IReadOnlyList? Apply) +{ + public const int CurrentSchemaVersion = 1; +} + +public sealed record SelfUpdateStageResult( + LauncherVersion Version, + string PendingPlanPath, + string Status); + +/// +/// Durable self-update transaction owner. It stages a verified launcher ZIP; +/// a separately copied helper performs move-only replacement after the parent +/// exits and can replay rollback after a crash at any file boundary. +/// +public sealed class LauncherSelfUpdateManager +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 32, + Converters = { new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false) }, + }; + + private readonly VerifiedArtifactDownloader _downloader; + private readonly SafeZipExtractor _extractor; + + public LauncherSelfUpdateManager( + ApplicationPathSet paths, + HttpClient httpClient, + SafeZipExtractor? extractor = null) + { + ArgumentNullException.ThrowIfNull(paths); + RootDirectory = Path.Combine( + Path.GetFullPath(paths.DataDirectory), + "launcher-update"); + TransactionsDirectory = Path.Combine(RootDirectory, "transactions"); + PendingPlanPath = Path.Combine(RootDirectory, "pending.json"); + Barrier = new UpdateSessionBarrier(paths.DataDirectory); + _downloader = new VerifiedArtifactDownloader( + httpClient ?? throw new ArgumentNullException(nameof(httpClient))); + _extractor = extractor ?? new SafeZipExtractor(); + } + + public string RootDirectory { get; } + + public string TransactionsDirectory { get; } + + public string PendingPlanPath { get; } + + public UpdateSessionBarrier Barrier { get; } + + public async Task StageAsync( + ReleaseManifest manifest, + string rid, + string targetDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + ReleaseArtifact artifact = manifest.RequireLauncher(rid); + return await StageAsync( + manifest.Version, + rid, + artifact, + targetDirectory, + progress, + cancellationToken) + .ConfigureAwait(false); + } + + internal async Task StageAsync( + LauncherVersion version, + string rid, + ReleaseArtifact artifact, + string targetDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(artifact); + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + + string target = NormalizeTargetDirectory(targetDirectory); + Directory.CreateDirectory(RootDirectory); + Directory.CreateDirectory(TransactionsDirectory); + SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (existing is not null) + { + throw new LauncherUpdateException( + $"Launcher self-update {existing.Version} is already {existing.State}. " + + "Restart the launcher to finish it before staging another."); + } + + string transactionId = Guid.NewGuid().ToString("N"); + string transactionDirectory = GetTransactionDirectory(transactionId); + string payloadDirectory = GetPayloadDirectory(transactionId); + string archivePath = Path.Combine(transactionDirectory, "launcher.zip"); + Directory.CreateDirectory(transactionDirectory); + try + { + _ = await _downloader.DownloadAsync( + artifact, + archivePath, + progress, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList extracted = await _extractor.ExtractAsync( + archivePath, + payloadDirectory, + cancellationToken) + .ConfigureAwait(false); + ClientVersionStore.ValidateRequiredExecutables( + extracted, + rid, + launcherPayload: true); + + var plan = new SelfUpdatePlan( + SelfUpdatePlan.CurrentSchemaVersion, + transactionId, + SelfUpdatePlanState.Staged, + version.Value, + rid, + target, + artifact.Sha256.ToLowerInvariant(), + artifact.Size, + extracted.Select(file => new InstalledFileRecord( + file.Path, + file.Sha256, + file.Size, + file.UnixMode)) + .OrderBy(file => file.Path, StringComparer.Ordinal) + .ToArray(), + null); + ValidatePlan(plan, target); + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + VerifiedArtifactDownloader.TryDelete(archivePath); + return new SelfUpdateStageResult( + version, + PendingPlanPath, + $"Launcher {version} is staged and will be applied on next start."); + } + catch + { + if (!File.Exists(PendingPlanPath)) + { + SafeZipExtractor.TryDeleteDirectory(transactionDirectory); + } + + throw; + } + } + + public async Task LoadPendingAsync( + CancellationToken cancellationToken = default) + { + if (!File.Exists(PendingPlanPath)) + { + CleanupOwnedResidue(keepTransactionId: null); + return null; + } + + try + { + byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken) + .ConfigureAwait(false); + SelfUpdatePlan? plan = ClientVersionStore.ParseStrict( + bytes, + SerializerOptions); + if (plan is null) + { + throw new LauncherUpdateException("The self-update plan is empty."); + } + + ValidatePlan(plan, plan.TargetDirectory); + CleanupOwnedResidue(plan.TransactionId); + return plan; + } + catch (OperationCanceledException) + { + throw; + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or FormatException + or NotSupportedException) + { + throw new LauncherUpdateException( + $"The pending launcher self-update is invalid: {ex.Message}", + ex); + } + } + + public async Task ApplyPendingAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no staged launcher self-update."); + ValidatePlan(plan, expectedTarget); + + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + return plan; + } + + if (plan.State == SelfUpdatePlanState.Applying) + { + plan = await RollbackApplyingAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); + var apply = new List(plan.Files.Count); + foreach (InstalledFileRecord file in plan.Files) + { + string targetPath = ClientVersionStore.ResolveContained( + expectedTarget, + file.Path); + if (Directory.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Self-update target '{file.Path}' is unexpectedly a directory."); + } + + apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath))); + } + + plan = plan with + { + State = SelfUpdatePlanState.Applying, + Apply = apply, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + + string payload = GetPayloadDirectory(plan.TransactionId); + string backup = GetBackupDirectory(plan.TransactionId); + try + { + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); + string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + EnsureSafeParent(expectedTarget, targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + if (entry.HadOriginal) + { + File.Move(targetPath, backupPath); + } + + File.Move(stagedPath, targetPath); + InstalledFileRecord file = plan.Files.Single(candidate => + string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal)); + if (OperatingSystem.IsLinux() && file.UnixMode != 0) + { + File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode); + } + } + + plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + return plan; + } + catch + { + await RollbackApplyingAsync(plan, CancellationToken.None) + .ConfigureAwait(false); + throw; + } + } + + public async Task RecoverApplyingAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no pending self-update."); + ValidatePlan(plan, expectedTarget); + return plan.State == SelfUpdatePlanState.Applying + ? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false) + : plan; + } + + public async Task ConfirmAsync( + string transactionId, + string expectedTargetDirectory, + string currentExecutablePath, + CancellationToken cancellationToken = default) + { + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to confirm."); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + ValidatePlan(plan, expectedTarget); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.AwaitingConfirmation) + { + throw new LauncherUpdateException( + "The running launcher does not match the pending confirmation plan."); + } + + string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + string expectedExecutable = ClientVersionStore.ResolveContained( + expectedTarget, + "acdream-launcher" + suffix); + if (!PathsEqual(expectedExecutable, currentExecutablePath)) + { + throw new LauncherUpdateException( + "Only the newly installed launcher executable may confirm self-update."); + } + + await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + string confirmationPath = GetConfirmationPath(transactionId); + await AtomicJsonFile.WriteBytesAsync( + confirmationPath, + "confirmed"u8.ToArray(), + cancellationToken) + .ConfigureAwait(false); + } + + public bool IsConfirmed(string transactionId) => + File.Exists(GetConfirmationPath(transactionId)); + + public async Task CompleteConfirmedAsync( + string transactionId, + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to complete."); + ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory)); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.AwaitingConfirmation + || !IsConfirmed(transactionId)) + { + throw new LauncherUpdateException("The self-update is not confirmed."); + } + + File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); + } + + public async Task RollbackAwaitingConfirmationAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to roll back."); + ValidatePlan(plan, expectedTarget); + if (plan.State != SelfUpdatePlanState.AwaitingConfirmation) + { + throw new LauncherUpdateException( + "The pending self-update is not awaiting confirmation."); + } + + plan = plan with { State = SelfUpdatePlanState.Applying }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + return await RollbackApplyingAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + + public string GetTransactionDirectory(string transactionId) + { + RequireTransactionId(transactionId); + return Path.Combine(TransactionsDirectory, transactionId); + } + + public string GetPayloadDirectory(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "payload"); + + public string GetBackupDirectory(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "backup"); + + public string GetConfirmationPath(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "confirmed"); + + public string GetHelperPath(string transactionId) + { + string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return Path.Combine( + GetTransactionDirectory(transactionId), + "acdream-self-update-helper" + suffix); + } + + private async Task RollbackApplyingAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null) + { + throw new LauncherUpdateException("The self-update rollback journal is missing."); + } + + string payload = GetPayloadDirectory(plan.TransactionId); + string backup = GetBackupDirectory(plan.TransactionId); + foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse()) + { + cancellationToken.ThrowIfCancellationRequested(); + string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); + string targetPath = ClientVersionStore.ResolveContained( + plan.TargetDirectory, + entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + if (!File.Exists(stagedPath) && File.Exists(targetPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!); + File.Move(targetPath, stagedPath); + } + + if (entry.HadOriginal && File.Exists(backupPath)) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Move(backupPath, targetPath); + } + else if (!entry.HadOriginal && File.Exists(targetPath)) + { + File.Delete(targetPath); + } + } + + SafeZipExtractor.TryDeleteDirectory(backup); + plan = plan with + { + State = SelfUpdatePlanState.Staged, + Apply = null, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); + return plan; + } + + private async Task VerifyPayloadAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + string payload = GetPayloadDirectory(plan.TransactionId); + if (!Directory.Exists(payload)) + { + throw new LauncherUpdateException("The staged launcher payload is missing."); + } + + ClientVersionStore.RejectReparseTree(payload); + string[] actual = Directory.EnumerateFiles( + payload, + "*", + SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] expected = plan.Files + .Select(file => file.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The staged launcher contains missing or unrecorded files."); + } + + foreach (InstalledFileRecord file in plan.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ClientVersionStore.ResolveContained(payload, file.Path); + var info = new FileInfo(path); + if (!info.Exists || info.Length != file.Size) + { + throw new LauncherUpdateException( + $"Staged launcher file '{file.Path}' size is corrupt."); + } + + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + $"Staged launcher file '{file.Path}' SHA-256 is corrupt."); + } + } + } + + private static async Task VerifyAppliedTargetsAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + foreach (InstalledFileRecord file in plan.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path); + EnsureSafeParent(targetDirectory, path); + var info = new FileInfo(path); + if (!info.Exists + || (info.Attributes & FileAttributes.ReparsePoint) != 0 + || info.Length != file.Size) + { + throw new LauncherUpdateException( + $"Applied launcher file '{file.Path}' is missing, linked, or corrupt."); + } + + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + $"Applied launcher file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + throw new LauncherUpdateException( + $"Applied launcher file '{file.Path}' mode is corrupt."); + } + } + } + + private async Task WritePlanAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + ValidatePlan(plan, plan.TargetDirectory); + await AtomicJsonFile.WriteAsync( + PendingPlanPath, + plan, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + } + + private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory) + { + if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion) + { + throw new LauncherUpdateException( + $"Self-update schema version {plan.SchemaVersion} is not supported."); + } + + RequireTransactionId(plan.TransactionId); + if (!LauncherVersion.TryParse(plan.Version, out _) + || !LauncherRuntimeIdentity.IsValidRid(plan.Rid) + || !ReleaseManifestClient.IsSha256(plan.ArchiveSha256) + || plan.ArchiveSize <= 0 + || plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes) + { + throw new LauncherUpdateException("The self-update plan metadata is invalid."); + } + + string target = NormalizeTargetDirectory(plan.TargetDirectory); + if (!PathsEqual(target, expectedTargetDirectory)) + { + throw new LauncherUpdateException( + "The self-update target does not match the running launcher directory."); + } + + if (plan.Files is null || plan.Files.Count == 0) + { + throw new LauncherUpdateException("The self-update file list is empty."); + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in plan.Files) + { + if (!ClientVersionStore.IsNormalizedRelative(file.Path) + || !paths.Add(file.Path) + || !ReleaseManifestClient.IsSha256(file.Sha256) + || file.Size < 0 + || file.UnixMode is < 0 or > 0x1FF + || (prior is not null + && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + "The self-update file list is invalid, duplicated, or unsorted."); + } + + prior = file.Path; + } + + string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) + ? ".exe" + : string.Empty; + if (!paths.Contains("acdream-launcher" + suffix)) + { + throw new LauncherUpdateException( + "The self-update plan lacks the launcher root executable."); + } + + if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null + || plan.State != SelfUpdatePlanState.Staged && plan.Apply is null) + { + throw new LauncherUpdateException( + "The self-update apply journal does not match its state."); + } + + if (plan.Apply is not null) + { + if (plan.Apply.Count != plan.Files.Count + || !plan.Apply.Select(entry => entry.Path) + .SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The self-update apply journal does not match the file list."); + } + } + + string transactionDirectory = GetTransactionDirectory(plan.TransactionId); + if (!IsContained(TransactionsDirectory, transactionDirectory)) + { + throw new LauncherUpdateException("The self-update transaction path escaped."); + } + } + + private static string NormalizeTargetDirectory(string targetDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory); + if (!Path.IsPathFullyQualified(targetDirectory)) + { + throw new LauncherUpdateException( + "The self-update target directory must be absolute."); + } + + string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory)); + if (!Directory.Exists(target) + || (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + "The self-update target directory is missing or is a reparse point."); + } + + return target; + } + + private static void EnsureSafeParent(string root, string filePath) + { + string? parent = Path.GetDirectoryName(filePath); + if (parent is null) + { + throw new LauncherUpdateException("A self-update target has no parent."); + } + + Directory.CreateDirectory(parent); + for (var directory = new DirectoryInfo(parent); + directory is not null && IsContained(root, directory.FullName); + directory = directory.Parent) + { + if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Self-update target parent '{directory.FullName}' is a reparse point."); + } + + if (PathsEqual(directory.FullName, root)) + { + break; + } + } + } + + private static bool IsContained(string root, string path) + { + string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string fullPath = Path.GetFullPath(path); + return PathsEqual(fullRoot, fullPath) + || fullPath.StartsWith( + fullRoot + Path.DirectorySeparatorChar, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static void RequireTransactionId(string transactionId) + { + if (transactionId.Length != 32 + || !Guid.TryParseExact(transactionId, "N", out Guid parsed) + || !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal)) + { + throw new LauncherUpdateException("The self-update transaction id is invalid."); + } + } + + private void CleanupOwnedResidue(string? keepTransactionId) + { + if (Directory.Exists(TransactionsDirectory)) + { + foreach (string directory in Directory.EnumerateDirectories( + TransactionsDirectory, + "*", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(directory); + if (name.Length == 32 + && Guid.TryParseExact(name, "N", out Guid transaction) + && string.Equals( + transaction.ToString("N"), + name, + StringComparison.Ordinal) + && !string.Equals(name, keepTransactionId, StringComparison.Ordinal)) + { + SafeZipExtractor.TryDeleteDirectory(directory); + } + } + } + + if (!Directory.Exists(RootDirectory)) + { + return; + } + + foreach (string temporary in Directory.EnumerateFiles( + RootDirectory, + ".pending.json.*.tmp", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(temporary); + string prefix = ".pending.json."; + string transaction = name[prefix.Length..^".tmp".Length]; + if (Guid.TryParseExact(transaction, "N", out _)) + { + VerifiedArtifactDownloader.TryDelete(temporary); + } + } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs new file mode 100644 index 00000000..810ac57c --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs @@ -0,0 +1,457 @@ +namespace AcDream.Launcher.Core.Updates; + +public enum LauncherUpdatePhase +{ + Idle, + Checking, + DownloadingClient, + ExtractingClient, + ActivatingClient, + DownloadingLauncher, + StagingLauncher, + RollingBack, + Completed, + Cancelled, + Failed, +} + +public sealed record LauncherUpdateProgress( + LauncherUpdatePhase Phase, + string Status, + long Completed = 0, + long Total = 0) +{ + public double Percent => Total <= 0 + ? 0 + : Math.Clamp(Completed * 100d / Total, 0, 100); +} + +public sealed record LauncherUpdateCheckResult( + ReleaseManifest Manifest, + string Rid, + LauncherVersion LauncherVersion, + LauncherVersion? InstalledClientVersion, + bool IsClientUpdateAvailable, + bool IsLauncherUpdateAvailable, + bool IsLauncherMinimumSatisfied, + string Status); + +public interface ILauncherUpdater +{ + ClientVersionResolution CurrentClient { get; } + + Task InitializeAsync( + CancellationToken cancellationToken = default); + + Task CheckAsync( + CancellationToken cancellationToken = default); + + Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +/// +/// Canonical LA10 update transaction. It holds the cross-process exclusive +/// barrier for recovery/download/extraction/promotion/pointer publication and +/// leaves LA9's verified DAT/pak record untouched. +/// +public sealed class LauncherUpdater : ILauncherUpdater +{ + private readonly IReleaseManifestClient _manifestClient; + private readonly ClientVersionStore _versions; + private readonly LauncherSelfUpdateManager _selfUpdates; + private readonly VerifiedArtifactDownloader _downloader; + private readonly SafeZipExtractor _extractor; + private readonly LauncherVersion _launcherVersion; + private readonly string _rid; + private readonly string _launcherTargetDirectory; + private readonly Func _hasRunningSessions; + private readonly SemaphoreSlim _operationGate = new(1, 1); + + public LauncherUpdater( + IReleaseManifestClient manifestClient, + HttpClient httpClient, + ClientVersionStore versions, + LauncherSelfUpdateManager selfUpdates, + LauncherVersion launcherVersion, + string rid, + string launcherTargetDirectory, + Func? hasRunningSessions = null, + SafeZipExtractor? extractor = null) + { + _manifestClient = manifestClient + ?? throw new ArgumentNullException(nameof(manifestClient)); + _versions = versions ?? throw new ArgumentNullException(nameof(versions)); + _selfUpdates = selfUpdates ?? throw new ArgumentNullException(nameof(selfUpdates)); + _launcherVersion = launcherVersion + ?? throw new ArgumentNullException(nameof(launcherVersion)); + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + + _rid = rid; + ArgumentException.ThrowIfNullOrWhiteSpace(launcherTargetDirectory); + _launcherTargetDirectory = Path.GetFullPath(launcherTargetDirectory); + _hasRunningSessions = hasRunningSessions ?? (() => false); + _downloader = new VerifiedArtifactDownloader( + httpClient ?? throw new ArgumentNullException(nameof(httpClient))); + _extractor = extractor ?? new SafeZipExtractor(); + } + + public ClientVersionResolution CurrentClient => _versions.CachedResolution; + + public Task InitializeAsync( + CancellationToken cancellationToken = default) => + _versions.LoadAndRecoverAsync(_rid, cancellationToken); + + public async Task CheckAsync( + CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ReleaseManifest manifest = await _manifestClient.FetchAsync(cancellationToken) + .ConfigureAwait(false); + _ = manifest.RequireClient(_rid); + _ = manifest.RequireLauncher(_rid); + ClientVersionResolution installed = _versions.CachedResolution; + LauncherVersion? installedVersion = installed.IsVerified + ? installed.Version + : null; + bool clientAvailable = installedVersion is null + || manifest.Version > installedVersion; + bool launcherAvailable = manifest.Version > _launcherVersion; + bool minimumSatisfied = _launcherVersion >= manifest.MinimumLauncherVersion; + string status = BuildCheckStatus( + manifest, + installedVersion, + clientAvailable, + launcherAvailable, + minimumSatisfied); + return new LauncherUpdateCheckResult( + manifest, + _rid, + _launcherVersion, + installedVersion, + clientAvailable, + launcherAvailable, + minimumSatisfied, + status); + } + finally + { + _operationGate.Release(); + } + } + + public async Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + ValidateCheck(check); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + using UpdateSessionBarrier.ExclusiveLease lease = + _versions.Barrier.AcquireExclusive(); + RefuseRunningSessions(); + ClientVersionResolution current = await _versions + .LoadAndRecoverUnderLeaseAsync(_rid, cancellationToken) + .ConfigureAwait(false); + if (!check.IsLauncherMinimumSatisfied) + { + throw new LauncherUpdateException( + $"Client {check.Manifest.Version} requires launcher " + + $"{check.Manifest.MinimumLauncherVersion} or newer. " + + "Stage the launcher update first."); + } + + if (current.IsVerified + && current.Version is not null + && current.Version >= check.Manifest.Version) + { + Report( + progress, + LauncherUpdatePhase.Completed, + $"Client {current.Version} is already current.", + 1, + 1); + return current; + } + + ReleaseArtifact artifact = check.Manifest.RequireClient(_rid); + Guid transactionId = Guid.NewGuid(); + string staging = _versions.CreateClientStagingDirectory(transactionId); + string archive = Path.Combine( + _versions.AppDirectory, + $".client-download-{transactionId:N}.zip"); + try + { + Report( + progress, + LauncherUpdatePhase.DownloadingClient, + $"Downloading client {check.Manifest.Version}...", + 0, + artifact.Size); + var downloadProgress = new ForwardProgress(value => + Report( + progress, + LauncherUpdatePhase.DownloadingClient, + $"Downloading client {check.Manifest.Version}: " + + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", + value.BytesReceived, + value.TotalBytes)); + _ = await _downloader.DownloadAsync( + artifact, + archive, + downloadProgress, + cancellationToken) + .ConfigureAwait(false); + + Report( + progress, + LauncherUpdatePhase.ExtractingClient, + "Verifying paths and extracting the client archive..."); + IReadOnlyList files = await _extractor.ExtractAsync( + archive, + staging, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.ActivatingClient, + $"Atomically activating client {check.Manifest.Version}..."); + ClientVersionResolution result = await _versions + .PromoteAndActivateUnderLeaseAsync( + staging, + check.Manifest.Version, + _rid, + artifact, + files, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.Completed, + $"Client {check.Manifest.Version} installed and activated.", + 1, + 1); + return result; + } + catch (OperationCanceledException) + { + Report( + progress, + LauncherUpdatePhase.Cancelled, + "Client update cancelled; the active version was not changed."); + throw; + } + catch (Exception ex) + { + Report( + progress, + LauncherUpdatePhase.Failed, + $"Client update failed: {ex.Message}"); + throw; + } + finally + { + VerifiedArtifactDownloader.TryDelete(archive); + SafeZipExtractor.TryDeleteDirectory(staging); + } + } + finally + { + _operationGate.Release(); + } + } + + public async Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + ValidateCheck(check); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + using UpdateSessionBarrier.ExclusiveLease lease = + _versions.Barrier.AcquireExclusive(); + RefuseRunningSessions(); + if (check.Manifest.Version <= _launcherVersion) + { + throw new LauncherUpdateException( + $"Launcher {_launcherVersion} is already current."); + } + + Report( + progress, + LauncherUpdatePhase.DownloadingLauncher, + $"Downloading launcher {check.Manifest.Version}..."); + var downloadProgress = new ForwardProgress(value => + Report( + progress, + LauncherUpdatePhase.DownloadingLauncher, + $"Downloading launcher {check.Manifest.Version}: " + + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", + value.BytesReceived, + value.TotalBytes)); + try + { + SelfUpdateStageResult result = await _selfUpdates.StageAsync( + check.Manifest, + _rid, + _launcherTargetDirectory, + downloadProgress, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.StagingLauncher, + result.Status, + 1, + 1); + return result; + } + catch (OperationCanceledException) + { + Report( + progress, + LauncherUpdatePhase.Cancelled, + "Launcher update staging cancelled."); + throw; + } + catch (Exception ex) + { + Report( + progress, + LauncherUpdatePhase.Failed, + $"Launcher update staging failed: {ex.Message}"); + throw; + } + } + finally + { + _operationGate.Release(); + } + } + + public async Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + Report( + progress, + LauncherUpdatePhase.RollingBack, + "Verifying and activating the previous client version..."); + ClientVersionResolution result = await _versions.RollbackAsync( + _rid, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.Completed, + $"Rolled back to client {result.Version}.", + 1, + 1); + return result; + } + finally + { + _operationGate.Release(); + } + } + + private void ValidateCheck(LauncherUpdateCheckResult check) + { + if (!string.Equals(check.Rid, _rid, StringComparison.Ordinal) + || !check.LauncherVersion.Equals(_launcherVersion)) + { + throw new LauncherUpdateException( + "The update check belongs to a different launcher runtime."); + } + + _ = check.Manifest.RequireClient(_rid); + _ = check.Manifest.RequireLauncher(_rid); + } + + private void RefuseRunningSessions() + { + if (_hasRunningSessions()) + { + throw new LauncherUpdateException( + "Stop every launcher session before installing or rolling back an update."); + } + } + + private static string BuildCheckStatus( + ReleaseManifest manifest, + LauncherVersion? installed, + bool clientAvailable, + bool launcherAvailable, + bool minimumSatisfied) + { + if (!minimumSatisfied) + { + return $"Release {manifest.Version} requires launcher " + + $"{manifest.MinimumLauncherVersion} or newer."; + } + + if (clientAvailable && launcherAvailable) + { + return $"Client and launcher {manifest.Version} are available."; + } + + if (clientAvailable) + { + return installed is null + ? $"Client {manifest.Version} is available for installation." + : $"Client update {installed} -> {manifest.Version} is available."; + } + + if (launcherAvailable) + { + return $"Launcher {manifest.Version} is available."; + } + + return "Client and launcher are up to date."; + } + + private static void Report( + IProgress? progress, + LauncherUpdatePhase phase, + string status, + long completed = 0, + long total = 0) => + progress?.Report(new LauncherUpdateProgress( + phase, + status, + completed, + total)); + + private sealed class ForwardProgress(Action callback) : IProgress + { + public void Report(T value) => callback(value); + } +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs b/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs new file mode 100644 index 00000000..bf152c08 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs @@ -0,0 +1,199 @@ +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.Launcher.Core.Updates; + +/// +/// Strict SemVer 2.0 value used by the release feed, client pointer, and +/// self-update plan. Numeric identifiers are compared as digit strings so a +/// maliciously large identifier cannot overflow a fixed-width integer. +/// +public sealed class LauncherVersion : IComparable, IEquatable +{ + private readonly string[] _core; + private readonly string[] _preRelease; + + private LauncherVersion( + string value, + string[] core, + string[] preRelease) + { + Value = value; + _core = core; + _preRelease = preRelease; + } + + public string Value { get; } + + public bool IsPreRelease => _preRelease.Length != 0; + + public static LauncherVersion Parse(string value) + { + if (!TryParse(value, out LauncherVersion? version)) + { + throw new FormatException($"'{value}' is not a strict SemVer 2.0 version."); + } + + return version; + } + + public static bool TryParse( + string? value, + [NotNullWhen(true)] out LauncherVersion? version) + { + version = null; + if (string.IsNullOrEmpty(value) + || value.Length > 128 + || !string.Equals(value, value.Trim(), StringComparison.Ordinal)) + { + return false; + } + + string precedence = value; + int plus = value.IndexOf('+', StringComparison.Ordinal); + if (plus >= 0) + { + if (plus == value.Length - 1 + || value.IndexOf('+', plus + 1) >= 0 + || !ValidIdentifiers(value[(plus + 1)..], numericLeadingZeroRule: false)) + { + return false; + } + + precedence = value[..plus]; + } + + string coreText = precedence; + string[] preRelease = []; + int dash = precedence.IndexOf('-', StringComparison.Ordinal); + if (dash >= 0) + { + if (dash == precedence.Length - 1 + || !ValidIdentifiers(precedence[(dash + 1)..], numericLeadingZeroRule: true)) + { + return false; + } + + coreText = precedence[..dash]; + preRelease = precedence[(dash + 1)..].Split('.'); + } + + string[] core = coreText.Split('.'); + if (core.Length != 3 || core.Any(part => !ValidCoreNumber(part))) + { + return false; + } + + version = new LauncherVersion(value, core, preRelease); + return true; + } + + public int CompareTo(LauncherVersion? other) + { + if (other is null) + { + return 1; + } + + for (int index = 0; index < _core.Length; index++) + { + int comparison = CompareNumeric(_core[index], other._core[index]); + if (comparison != 0) + { + return comparison; + } + } + + if (_preRelease.Length == 0 || other._preRelease.Length == 0) + { + return _preRelease.Length == other._preRelease.Length + ? 0 + : _preRelease.Length == 0 ? 1 : -1; + } + + int shared = Math.Min(_preRelease.Length, other._preRelease.Length); + for (int index = 0; index < shared; index++) + { + string left = _preRelease[index]; + string right = other._preRelease[index]; + bool leftNumeric = IsDigits(left); + bool rightNumeric = IsDigits(right); + int comparison = leftNumeric && rightNumeric + ? CompareNumeric(left, right) + : leftNumeric != rightNumeric + ? leftNumeric ? -1 : 1 + : string.Compare(left, right, StringComparison.Ordinal); + if (comparison != 0) + { + return comparison; + } + } + + return _preRelease.Length.CompareTo(other._preRelease.Length); + } + + public bool Equals(LauncherVersion? other) => + other is not null && CompareTo(other) == 0; + + public override bool Equals(object? obj) => Equals(obj as LauncherVersion); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (string part in _core) + { + hash.Add(part, StringComparer.Ordinal); + } + + hash.Add(_preRelease.Length); + foreach (string part in _preRelease) + { + hash.Add(part, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + + public override string ToString() => Value; + + public static bool operator >(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) > 0; + + public static bool operator <(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) < 0; + + public static bool operator >=(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) >= 0; + + public static bool operator <=(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) <= 0; + + private static bool ValidCoreNumber(string value) => + IsDigits(value) && (value.Length == 1 || value[0] != '0'); + + private static bool ValidIdentifiers(string value, bool numericLeadingZeroRule) + { + string[] identifiers = value.Split('.'); + return identifiers.All(identifier => + identifier.Length > 0 + && identifier.All(character => + character is >= '0' and <= '9' + or >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or '-') + && (!numericLeadingZeroRule + || !IsDigits(identifier) + || identifier.Length == 1 + || identifier[0] != '0')); + } + + private static bool IsDigits(string value) => + value.Length > 0 && value.All(character => character is >= '0' and <= '9'); + + private static int CompareNumeric(string left, string right) + { + int length = left.Length.CompareTo(right.Length); + return length != 0 + ? length + : string.Compare(left, right, StringComparison.Ordinal); + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs new file mode 100644 index 00000000..5d922641 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs @@ -0,0 +1,37 @@ +namespace AcDream.Launcher.Core.Updates; + +public sealed record ReleaseArtifact(Uri Url, string Sha256, long Size); + +public sealed record ReleaseManifest( + LauncherVersion Version, + LauncherVersion MinimumLauncherVersion, + IReadOnlyDictionary Clients, + IReadOnlyDictionary Launchers) +{ + public const int CurrentSchemaVersion = 1; + + public ReleaseArtifact RequireClient(string rid) => + Clients.TryGetValue(rid, out ReleaseArtifact? artifact) + ? artifact + : throw new LauncherUpdateException( + $"Release {Version} has no client payload for RID '{rid}'."); + + public ReleaseArtifact RequireLauncher(string rid) => + Launchers.TryGetValue(rid, out ReleaseArtifact? artifact) + ? artifact + : throw new LauncherUpdateException( + $"Release {Version} has no launcher payload for RID '{rid}'."); +} + +public sealed class LauncherUpdateException : Exception +{ + public LauncherUpdateException(string message) + : base(message) + { + } + + public LauncherUpdateException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs new file mode 100644 index 00000000..6718ddcb --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -0,0 +1,300 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Updates; + +public interface IReleaseManifestClient +{ + Task FetchAsync(CancellationToken cancellationToken = default); +} + +/// +/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is +/// accepted only for a loopback fixture; production and artifact URLs are +/// HTTPS-only. +/// +public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable +{ + public const string GitHubOwner = "eriknihlen"; + public const string GitHubRepository = "acdream"; + public const int MaximumManifestBytes = 1024 * 1024; + public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024; + + public static Uri ProductionManifestUri { get; } = new( + $"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json"); + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 16, + }; + + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly Uri _manifestUri; + + public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null) + { + _httpClient = httpClient ?? new HttpClient(); + _ownsHttpClient = httpClient is null; + _manifestUri = manifestUri ?? ProductionManifestUri; + RequireSecureOrLoopback(_manifestUri, "manifest"); + if (_ownsHttpClient) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + } + } + + public async Task FetchAsync( + CancellationToken cancellationToken = default) + { + try + { + using HttpResponseMessage response = await _httpClient.GetAsync( + _manifestUri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri; + RequireSecureOrLoopback(finalUri, "manifest redirect"); + if (response.Content.Headers.ContentLength is long contentLength + && contentLength > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + await using Stream input = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var output = new MemoryStream(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await input.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (output.Length + read > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + output.Write(buffer, 0, read); + } + + return Parse(output.ToArray()); + } + catch (OperationCanceledException) + { + throw; + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException + or IOException + or JsonException + or NotSupportedException) + { + throw new LauncherUpdateException( + $"The release manifest could not be loaded: {ex.Message}", + ex); + } + } + + internal static ReleaseManifest Parse(ReadOnlySpan utf8) + { + try + { + using JsonDocument document = JsonDocument.Parse( + utf8.ToArray(), + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 16, + }); + RejectDuplicateProperties(document.RootElement, "$" ); + ManifestDocument? value = document.RootElement.Deserialize( + SerializerOptions); + return Validate(value); + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is JsonException + or FormatException + or InvalidOperationException) + { + throw new LauncherUpdateException( + $"The release manifest is invalid: {ex.Message}", + ex); + } + } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + internal static void RequireSecureOrLoopback(Uri uri, string description) + { + if (!uri.IsAbsoluteUri + || (uri.Scheme != Uri.UriSchemeHttps + && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) + { + throw new LauncherUpdateException( + $"The {description} URI must use HTTPS (loopback HTTP is test-only)."); + } + } + + private static ReleaseManifest Validate(ManifestDocument? document) + { + if (document is null) + { + throw new LauncherUpdateException("The release manifest is empty."); + } + + if (document.SchemaVersion != ReleaseManifest.CurrentSchemaVersion) + { + throw new LauncherUpdateException( + $"Release manifest schema version {document.SchemaVersion} is not supported."); + } + + LauncherVersion version = LauncherVersion.Parse( + document.Version + ?? throw new LauncherUpdateException("The release version is missing.")); + LauncherVersion minimum = LauncherVersion.Parse( + document.MinimumLauncherVersion + ?? throw new LauncherUpdateException( + "The minimum launcher version is missing.")); + if (minimum > version) + { + throw new LauncherUpdateException( + "The minimum launcher version cannot exceed the release version."); + } + IReadOnlyDictionary clients = ValidateArtifacts( + document.Clients, + "clients"); + IReadOnlyDictionary launchers = ValidateArtifacts( + document.Launchers, + "launchers"); + return new ReleaseManifest(version, minimum, clients, launchers); + } + + private static IReadOnlyDictionary ValidateArtifacts( + Dictionary? artifacts, + string field) + { + if (artifacts is null || artifacts.Count == 0) + { + throw new LauncherUpdateException($"Manifest field '{field}' must not be empty."); + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach ((string rid, ArtifactDocument value) in artifacts) + { + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new LauncherUpdateException( + $"Manifest field '{field}' contains invalid RID '{rid}'."); + } + + if (value is null) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' is null."); + } + + if (!Uri.TryCreate(value.Url, UriKind.Absolute, out Uri? uri)) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid URL."); + } + + RequireSecureOrLoopback(uri, $"{field}.{rid} artifact"); + if (!IsSha256(value.Sha256)) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid SHA-256 digest."); + } + + if (value.Size <= 0 || value.Size > MaximumArtifactBytes) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid size."); + } + + result.Add( + rid, + new ReleaseArtifact(uri, value.Sha256!.ToLowerInvariant(), value.Size)); + } + + return result; + } + + internal static bool IsSha256(string? value) => + value is { Length: 64 } && value.All(Uri.IsHexDigit); + + private static void RejectDuplicateProperties(JsonElement element, string path) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new LauncherUpdateException( + $"Duplicate JSON property '{path}.{property.Name}' is not allowed."); + } + + RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{path}[{index++}]"); + } + } + } + + private sealed class ManifestDocument + { + public int SchemaVersion { get; init; } + + public string? Version { get; init; } + + public string? MinimumLauncherVersion { get; init; } + + public Dictionary? Clients { get; init; } + + public Dictionary? Launchers { get; init; } + } + + private sealed class ArtifactDocument + { + public string? Url { get; init; } + + public string? Sha256 { get; init; } + + public long Size { get; init; } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs new file mode 100644 index 00000000..6d2c77ee --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs @@ -0,0 +1,482 @@ +using System.Buffers; +using System.IO.Compression; +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record SafeZipExtractionLimits( + int MaximumEntries = 20_000, + long MaximumEntryBytes = 2L * 1024 * 1024 * 1024, + long MaximumTotalBytes = 8L * 1024 * 1024 * 1024, + double MaximumCompressionRatio = 200, + int MaximumRelativePathLength = 512); + +public sealed record ExtractedFileRecord( + string Path, + string Sha256, + long Size, + int UnixMode); + +/// +/// Portable ZIP extractor for release assets. The complete central-directory +/// shape is validated before the first output path is created. +/// +public sealed class SafeZipExtractor +{ + private const int BufferSize = 128 * 1024; + private const int UnixTypeMask = 0xF000; + private const int UnixRegularFile = 0x8000; + private const int UnixDirectory = 0x4000; + private const int UnixPermissionMask = 0x1FF; + private readonly SafeZipExtractionLimits _limits; + + public SafeZipExtractor(SafeZipExtractionLimits? limits = null) + { + _limits = limits ?? new SafeZipExtractionLimits(); + if (_limits.MaximumEntries <= 0 + || _limits.MaximumEntryBytes <= 0 + || _limits.MaximumTotalBytes <= 0 + || _limits.MaximumCompressionRatio <= 0 + || _limits.MaximumRelativePathLength <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(limits), + "ZIP extraction limits must all be positive."); + } + } + + public async Task> ExtractAsync( + string archivePath, + string destinationDirectory, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + string archive = Path.GetFullPath(archivePath); + string destination = Path.GetFullPath(destinationDirectory); + + if (Directory.Exists(destination) + && Directory.EnumerateFileSystemEntries(destination).Any()) + { + throw new LauncherUpdateException( + "The ZIP extraction destination must be empty."); + } + + try + { + await using var stream = new FileStream( + archive, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + IReadOnlyList entries = ValidateArchive(zip); + + Directory.CreateDirectory(destination); + RejectReparsePoint(destination, "extraction root"); + foreach (string directory in entries + .SelectMany(entry => ParentPaths(entry.RelativePath)) + .Concat(entries.Where(entry => entry.IsDirectory) + .Select(entry => entry.RelativePath)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path.Count(character => character == '/')) + .ThenBy(path => path, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + string directoryPath = ResolveContained(destination, directory); + Directory.CreateDirectory(directoryPath); + RejectReparsePoint(directoryPath, $"directory '{directory}'"); + } + + var files = new List(); + long actualTotal = 0; + foreach (ValidatedEntry entry in entries.Where(entry => !entry.IsDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + string outputPath = ResolveContained(destination, entry.RelativePath); + EnsureParentsAreDirectories(destination, entry.RelativePath); + await using Stream input = entry.Entry.Open(); + await using var output = new FileStream( + outputPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + BufferSize, + FileOptions.Asynchronous + | FileOptions.SequentialScan + | FileOptions.WriteThrough); + using IncrementalHash hash = IncrementalHash.CreateHash( + HashAlgorithmName.SHA256); + byte[] buffer = ArrayPool.Shared.Rent(BufferSize); + long actualEntry = 0; + try + { + while (true) + { + int read = await input.ReadAsync( + buffer.AsMemory(0, BufferSize), + cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + actualEntry = checked(actualEntry + read); + actualTotal = checked(actualTotal + read); + if (actualEntry > entry.Entry.Length + || actualEntry > _limits.MaximumEntryBytes + || actualTotal > _limits.MaximumTotalBytes) + { + throw new LauncherUpdateException( + $"ZIP entry '{entry.RelativePath}' exceeded its declared limits."); + } + + hash.AppendData(buffer, 0, read); + await output.WriteAsync( + buffer.AsMemory(0, read), + cancellationToken) + .ConfigureAwait(false); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + + if (actualEntry != entry.Entry.Length) + { + throw new LauncherUpdateException( + $"ZIP entry '{entry.RelativePath}' length changed while extracting."); + } + + int unixMode = entry.UnixMode & UnixPermissionMask; + if (OperatingSystem.IsLinux() && unixMode != 0) + { + File.SetUnixFileMode(outputPath, (UnixFileMode)unixMode); + } + + files.Add(new ExtractedFileRecord( + entry.RelativePath, + Convert.ToHexStringLower(hash.GetHashAndReset()), + actualEntry, + unixMode)); + } + + files.Sort((left, right) => string.Compare( + left.Path, + right.Path, + StringComparison.Ordinal)); + return files; + } + catch (OperationCanceledException) + { + TryDeleteDirectory(destination); + throw; + } + catch (LauncherUpdateException) + { + TryDeleteDirectory(destination); + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidDataException + or NotSupportedException + or CryptographicException) + { + TryDeleteDirectory(destination); + throw new LauncherUpdateException( + $"The release ZIP could not be extracted safely: {ex.Message}", + ex); + } + } + + private IReadOnlyList ValidateArchive(ZipArchive zip) + { + if (zip.Entries.Count == 0 || zip.Entries.Count > _limits.MaximumEntries) + { + throw new LauncherUpdateException( + $"ZIP entry count {zip.Entries.Count} is outside the allowed range."); + } + + var result = new List(zip.Entries.Count); + var explicitEntries = new HashSet(StringComparer.OrdinalIgnoreCase); + var nodes = new Dictionary(StringComparer.OrdinalIgnoreCase); + long totalLength = 0; + long totalCompressed = 0; + foreach (ZipArchiveEntry entry in zip.Entries) + { + string relative = NormalizeEntryPath(entry.FullName); + if (!explicitEntries.Add(relative)) + { + throw new LauncherUpdateException( + $"ZIP contains a duplicate/case-colliding entry '{relative}'."); + } + + int unixAttributes = entry.ExternalAttributes >> 16; + int unixType = unixAttributes & UnixTypeMask; + bool trailingDirectory = entry.FullName.EndsWith("/", StringComparison.Ordinal) + || entry.FullName.EndsWith("\\", StringComparison.Ordinal); + bool isDirectory = trailingDirectory || unixType == UnixDirectory; + if ((entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0 + || unixType is not (0 or UnixRegularFile or UnixDirectory) + || (unixType == UnixDirectory && !trailingDirectory) + || (isDirectory && (entry.Length != 0 || entry.CompressedLength != 0))) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' is a symlink, reparse point, or unsupported type."); + } + + AddPathNodes(nodes, relative, isDirectory); + if (!isDirectory) + { + if (entry.Length < 0 + || entry.CompressedLength < 0 + || entry.Length > _limits.MaximumEntryBytes) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' exceeds the per-file limit."); + } + + totalLength = checked(totalLength + entry.Length); + totalCompressed = checked(totalCompressed + entry.CompressedLength); + if (totalLength > _limits.MaximumTotalBytes + || IsRatioExceeded(entry.Length, entry.CompressedLength)) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' exceeds extraction size/ratio limits."); + } + } + + result.Add(new ValidatedEntry(entry, relative, isDirectory, unixAttributes)); + } + + if (totalLength > 0 + && (totalCompressed == 0 || IsRatioExceeded(totalLength, totalCompressed))) + { + throw new LauncherUpdateException( + "ZIP aggregate compression ratio exceeds the allowed limit."); + } + + return result; + } + + private string NormalizeEntryPath(string name) + { + if (string.IsNullOrEmpty(name) + || name.IndexOf('\0') >= 0 + || name.Contains(':', StringComparison.Ordinal)) + { + throw new LauncherUpdateException("ZIP contains an empty, NUL, or ADS path."); + } + + string normalized = name.Replace('\\', '/'); + bool directory = normalized.EndsWith("/", StringComparison.Ordinal); + normalized = normalized.TrimEnd('/'); + if (normalized.Length == 0 + || normalized.Length > _limits.MaximumRelativePathLength + || normalized.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(normalized)) + { + throw new LauncherUpdateException($"ZIP path '{name}' is rooted or too long."); + } + + string[] segments = normalized.Split('/'); + foreach (string segment in segments) + { + if (segment.Length == 0 + || segment is "." or ".." + || segment.EndsWith(' ') + || segment.EndsWith('.') + || segment.Any(character => + char.IsControl(character) + || character is '<' or '>' or '"' or '|' or '?' or '*') + || IsWindowsDeviceName(segment)) + { + throw new LauncherUpdateException( + $"ZIP path '{name}' contains an unsafe segment."); + } + } + + return string.Join('/', segments) + (directory ? "/" : string.Empty); + } + + private static void AddPathNodes( + Dictionary nodes, + string relative, + bool isDirectory) + { + string path = relative.TrimEnd('/'); + string[] segments = path.Split('/'); + string current = string.Empty; + for (int index = 0; index < segments.Length; index++) + { + current = current.Length == 0 + ? segments[index] + : current + "/" + segments[index]; + bool nodeIsDirectory = index < segments.Length - 1 || isDirectory; + if (nodes.TryGetValue(current, out PathNode? existing)) + { + if (!string.Equals(existing.Spelling, current, StringComparison.Ordinal) + || (!existing.IsDirectory || !nodeIsDirectory)) + { + throw new LauncherUpdateException( + $"ZIP path '{relative}' collides with '{existing.Spelling}'."); + } + + continue; + } + + nodes.Add(current, new PathNode(current, nodeIsDirectory)); + } + } + + private bool IsRatioExceeded(long expanded, long compressed) => + expanded > 0 + && (compressed <= 0 || expanded / (double)compressed > _limits.MaximumCompressionRatio); + + private static IEnumerable ParentPaths(string relative) + { + string path = relative.TrimEnd('/'); + int slash = path.IndexOf('/'); + while (slash >= 0) + { + yield return path[..slash]; + slash = path.IndexOf('/', slash + 1); + } + } + + private static string ResolveContained(string root, string relative) + { + string path = Path.GetFullPath( + Path.Combine(root, relative.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar))); + string prefix = Path.EndsInDirectorySeparator(root) + ? root + : root + Path.DirectorySeparatorChar; + if (!path.StartsWith( + prefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + $"ZIP path '{relative}' escaped the extraction directory."); + } + + return path; + } + + private static void EnsureParentsAreDirectories(string root, string relative) + { + foreach (string parent in ParentPaths(relative)) + { + string path = ResolveContained(root, parent); + if (!Directory.Exists(path)) + { + throw new LauncherUpdateException( + $"ZIP parent '{parent}' is not a directory."); + } + + RejectReparsePoint(path, $"directory '{parent}'"); + } + } + + private static void RejectReparsePoint(string path, string description) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"The {description} is a reparse point."); + } + } + + private static bool IsWindowsDeviceName(string segment) + { + string stem = segment.Split('.')[0]; + return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || (stem.Length == 4 + && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) + && stem[3] is >= '1' and <= '9'); + } + + internal static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + DeleteDirectoryWithoutFollowingReparsePoints(path); + } + } + catch + { + // The exact random staging name is reclaimed under the update lease. + } + } + + private static void DeleteDirectoryWithoutFollowingReparsePoints(string directory) + { + FileAttributes rootAttributes = File.GetAttributes(directory); + if ((rootAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteReparsePoint(directory); + return; + } + + foreach (string entry in Directory.EnumerateFileSystemEntries( + directory, + "*", + SearchOption.TopDirectoryOnly)) + { + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteReparsePoint(entry); + } + else if ((attributes & FileAttributes.Directory) != 0) + { + DeleteDirectoryWithoutFollowingReparsePoints(entry); + } + else + { + File.Delete(entry); + } + } + + Directory.Delete(directory, recursive: false); + } + + private static void DeleteReparsePoint(string path) + { + try + { + File.Delete(path); + } + catch (UnauthorizedAccessException) + { + Directory.Delete(path, recursive: false); + } + catch (IOException) + { + Directory.Delete(path, recursive: false); + } + } + + private sealed record PathNode(string Spelling, bool IsDirectory); + + private sealed record ValidatedEntry( + ZipArchiveEntry Entry, + string RelativePath, + bool IsDirectory, + int UnixMode); +} diff --git a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs new file mode 100644 index 00000000..6c73ff35 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -0,0 +1,85 @@ +namespace AcDream.Launcher.Core.Updates; + +/// +/// One portable OS-handle barrier shared by supervised sessions and held +/// exclusively by update/rollback/recovery transactions. File contents are +/// never authoritative. +/// +public sealed class UpdateSessionBarrier +{ + public const string LockFileName = ".update-session.lock"; + + private readonly string _lockPath; + + public UpdateSessionBarrier(string dataDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + _lockPath = Path.Combine( + Path.GetFullPath(dataDirectory), + "app", + LockFileName); + } + + public string LockPath => _lockPath; + + public SessionLease AcquireSession() + { + FileStream stream = Open(FileShare.ReadWrite, "A client update is in progress."); + return new SessionLease(stream); + } + + public ExclusiveLease AcquireExclusive() + { + FileStream stream = Open( + FileShare.None, + "A launcher session or another update transaction is running. " + + "Stop every launcher session before updating."); + return new ExclusiveLease(stream); + } + + private FileStream Open(FileShare share, string refusal) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + return new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + share, + bufferSize: 1, + FileOptions.None); + } + catch (IOException ex) + { + throw new LauncherUpdateException(refusal, ex); + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + + public sealed class SessionLease : IDisposable + { + private FileStream? _stream; + + internal SessionLease(FileStream stream) => _stream = stream; + + public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); + } + + public sealed class ExclusiveLease : IDisposable + { + private FileStream? _stream; + + internal ExclusiveLease(FileStream stream) => _stream = stream; + + public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); + } +} diff --git a/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs new file mode 100644 index 00000000..608207ff --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs @@ -0,0 +1,200 @@ +using System.Buffers; +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record ArtifactDownloadProgress(long BytesReceived, long TotalBytes) +{ + public double Percent => TotalBytes <= 0 + ? 0 + : Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100); +} + +public sealed record VerifiedArtifactDownload( + string FilePath, + long Size, + string Sha256); + +/// +/// Streams a bounded release asset directly to a caller-owned staging path, +/// computing SHA-256 during the write. A partial/cancelled/wrong artifact is +/// deleted before the call returns. +/// +public sealed class VerifiedArtifactDownloader +{ + private const int BufferSize = 128 * 1024; + private readonly HttpClient _httpClient; + + public VerifiedArtifactDownloader(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task DownloadAsync( + ReleaseArtifact artifact, + string destinationPath, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(artifact); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + ReleaseManifestClient.RequireSecureOrLoopback(artifact.Url, "artifact"); + if (artifact.Size <= 0 + || artifact.Size > ReleaseManifestClient.MaximumArtifactBytes + || !ReleaseManifestClient.IsSha256(artifact.Sha256)) + { + throw new LauncherUpdateException("The requested artifact metadata is invalid."); + } + + string fullPath = Path.GetFullPath(destinationPath); + Directory.CreateDirectory( + Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException( + "The artifact staging path has no parent directory.")); + + bool ownsDestination = false; + try + { + using HttpResponseMessage response = await _httpClient.GetAsync( + artifact.Url, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + ReleaseManifestClient.RequireSecureOrLoopback( + response.RequestMessage?.RequestUri ?? artifact.Url, + "artifact redirect"); + if (response.Content.Headers.ContentLength is long contentLength + && contentLength != artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact size header mismatch: expected {artifact.Size}, " + + $"received {contentLength}."); + } + + if (response.Content.Headers.ContentEncoding.Count != 0) + { + throw new LauncherUpdateException( + "Release artifact content encoding is not allowed."); + } + + await using Stream input = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + await using var output = new FileStream( + fullPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + BufferSize, + FileOptions.Asynchronous + | FileOptions.SequentialScan + | FileOptions.WriteThrough); + ownsDestination = true; + using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + byte[] buffer = ArrayPool.Shared.Rent(BufferSize); + long received = 0; + try + { + progress?.Report(new ArtifactDownloadProgress(0, artifact.Size)); + while (true) + { + int read = await input.ReadAsync( + buffer.AsMemory(0, BufferSize), + cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + received = checked(received + read); + if (received > artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact exceeded its declared size of {artifact.Size} bytes."); + } + + hash.AppendData(buffer, 0, read); + await output.WriteAsync( + buffer.AsMemory(0, read), + cancellationToken) + .ConfigureAwait(false); + progress?.Report(new ArtifactDownloadProgress(received, artifact.Size)); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + + if (received != artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact ended at {received} bytes; expected {artifact.Size}."); + } + + string actualSha256 = Convert.ToHexStringLower(hash.GetHashAndReset()); + if (!string.Equals( + actualSha256, + artifact.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + "Artifact SHA-256 does not match the release manifest."); + } + + return new VerifiedArtifactDownload(fullPath, received, actualSha256); + } + catch (OperationCanceledException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw; + } + catch (LauncherUpdateException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw; + } + catch (Exception ex) when (ex is HttpRequestException + or IOException + or UnauthorizedAccessException + or CryptographicException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw new LauncherUpdateException( + $"The release artifact could not be downloaded: {ex.Message}", + ex); + } + } + + internal static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // The exact random staging name is reclaimed by startup recovery. + } + } +} diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index 2723ed1d..dae76b75 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -1,7 +1,9 @@ +using System.Reflection; using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Orchestration; using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; using AcDream.Launcher.ViewModels; using AcDream.Platform; using Avalonia; @@ -14,6 +16,8 @@ public sealed partial class App : Application { private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; + private HttpClient? _updateHttpClient; + private ReleaseManifestClient? _manifestClient; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -23,6 +27,7 @@ public sealed partial class App : Application { ApplicationPathSet paths = ApplicationPathSet.Resolve(); LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); + string rid = LauncherRuntimeIdentity.DetectRid(); string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; var installer = new LauncherInstaller( paths, @@ -47,16 +52,38 @@ public sealed partial class App : Application $"Client content verification failed: {ex.Message}"); } + var clientVersions = new ClientVersionStore(paths); + _ = clientVersions.LoadAndRecoverAsync(rid) + .GetAwaiter() + .GetResult(); + _orchestrator = new LauncherOrchestrator( profiles, paths, - LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory), + LauncherExecutableSet.FromCurrentVersionStore(clientVersions), verification.Record, - installationStatus: verification.Status); + installationStatus: verification.Status, + updateSessionBarrier: clientVersions.Barrier); + _updateHttpClient = new HttpClient(); + _updateHttpClient.Timeout = TimeSpan.FromSeconds(15); + _updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd( + "acdream-launcher/1"); + _manifestClient = new ReleaseManifestClient(_updateHttpClient); + var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient); + var updater = new LauncherUpdater( + _manifestClient, + _updateHttpClient, + clientVersions, + selfUpdates, + GetLauncherVersion(), + rid, + AppContext.BaseDirectory, + () => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive)); _viewModel = new LauncherWindowViewModel( _orchestrator, new AvaloniaUiDispatcher(), - installer); + installer, + updater); _viewModel.Initialize(); desktop.MainWindow = new MainWindow @@ -73,7 +100,25 @@ public sealed partial class App : Application { _viewModel?.Dispose(); _orchestrator?.Dispose(); + _manifestClient?.Dispose(); + _updateHttpClient?.Dispose(); _viewModel = null; _orchestrator = null; + _manifestClient = null; + _updateHttpClient = null; + } + + private static LauncherVersion GetLauncherVersion() + { + string? informationalVersion = typeof(App).Assembly + .GetCustomAttribute()? + .InformationalVersion; + if (!LauncherVersion.TryParse(informationalVersion, out LauncherVersion? version)) + { + throw new InvalidOperationException( + $"Launcher informational version '{informationalVersion}' is not SemVer 2.0."); + } + + return version; } } diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 9af0408c..2f0a8515 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -45,7 +45,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor { + private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5); private readonly ILauncherChildProcessFactory _factory; private readonly object _gate = new(); private readonly Queue _pendingStateChanges = []; @@ -51,6 +52,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor private LauncherSessionState _state = LauncherSessionState.Starting; private int? _exitCode; private bool _publishingStateChanges; + private bool _disposed; public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) { @@ -100,6 +102,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor ILauncherChildProcess process; lock (_gate) { + ObjectDisposedException.ThrowIf(_disposed, this); if (_process is not null) { throw new InvalidOperationException( @@ -201,6 +204,11 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor if (!process.WaitForExit(timeout) && !process.HasExited) { process.Kill(); + if (!process.WaitForExit(Timeout.InfiniteTimeSpan) && !process.HasExited) + { + throw new InvalidOperationException( + "The launcher child could not be observed terminal after it was killed."); + } } } @@ -307,6 +315,23 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor public void Dispose() { + ILauncherChildProcess? process; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + process = _process; + } + + if (process is { HasExited: false }) + { + Stop(DisposeStopTimeout); + } + lock (_gate) { if (_process is not null) diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index 5fc2a858..4c3ffe16 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -149,6 +149,13 @@ public sealed class LauncherExecutableSet }); } + public static LauncherExecutableSet Unavailable(string reason) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + return new LauncherExecutableSet( + () => throw new LauncherUpdateException(reason)); + } + private ExecutablePaths RequireAvailable(LaunchMode mode) { LauncherCapability capability = GetAvailability(mode); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index d430eaad..a734fcaf 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -627,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator } request.Cancellation.Dispose(); + request.Activity.StartCompleted.Set(); } } @@ -1201,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator private static void DisposeActivity(ManagedActivity activity) { activity.StartCancellation?.Cancel(); + activity.StartCompleted.Wait(); activity.StartCancellation?.Dispose(); activity.StartCancellation = null; if (activity.Supervisor is not null) { + // Disposal is a process-lifetime transaction: the shared update + // lease remains held until Stop has observed the real child + // terminal (including the post-kill wait). + activity.Supervisor.Stop(TimeSpan.FromSeconds(5)); if (activity.SupervisorStateHandler is not null) { activity.Supervisor.StateChanged -= activity.SupervisorStateHandler; @@ -1216,6 +1222,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator } ReleaseUpdateSessionLease(activity); + activity.StartCompleted.Dispose(); } private static void ReleaseUpdateSessionLease(ManagedActivity activity) @@ -1285,6 +1292,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public UpdateSessionBarrier.SessionLease? UpdateSessionLease; + public ManualResetEventSlim StartCompleted { get; } = new(false); + public object StatusReadGate { get; } = new(); public bool IsActive => State is not ( diff --git a/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs index e14163b0..3b008184 100644 --- a/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs +++ b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs @@ -503,7 +503,9 @@ public sealed class ClientVersionStore .Where(path => !string.Equals( path, "install.json", - StringComparison.OrdinalIgnoreCase)) + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); string[] recordedFiles = record.Files @@ -809,13 +811,43 @@ public sealed class ClientVersionStore ".client-staging-*", SearchOption.TopDirectoryOnly)) { - string suffix = Path.GetFileName(path)[".client-staging-".Length..]; - if (Guid.TryParseExact(suffix, "N", out _)) + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-staging-", + string.Empty)) { SafeZipExtractor.TryDeleteDirectory(path); } } + foreach (string path in Directory.EnumerateDirectories( + AppDirectory, + ".client-corrupt-*", + SearchOption.TopDirectoryOnly)) + { + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-corrupt-", + string.Empty)) + { + SafeZipExtractor.TryDeleteDirectory(path); + } + } + + foreach (string path in Directory.EnumerateFiles( + AppDirectory, + ".client-download-*.zip", + SearchOption.TopDirectoryOnly)) + { + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-download-", + ".zip")) + { + VerifiedArtifactDownloader.TryDelete(path); + } + } + foreach (string path in Directory.EnumerateFiles( AppDirectory, ".current*.tmp", @@ -825,20 +857,43 @@ public sealed class ClientVersionStore string[] parts = fileName.Split('.'); if (parts.Length >= 4 && string.Equals(parts[^1], "tmp", StringComparison.Ordinal) - && Guid.TryParseExact(parts[^2], "N", out _)) + && Guid.TryParseExact(parts[^2], "N", out Guid parsed) + && string.Equals( + parsed.ToString("N"), + parts[^2], + StringComparison.Ordinal)) { VerifiedArtifactDownloader.TryDelete(path); } } } + private static bool HasCanonicalGuidName( + string fileName, + string prefix, + string suffix) + { + if (!fileName.StartsWith(prefix, StringComparison.Ordinal) + || !fileName.EndsWith(suffix, StringComparison.Ordinal) + || fileName.Length != prefix.Length + 32 + suffix.Length) + { + return false; + } + + string value = fileName.Substring(prefix.Length, 32); + return Guid.TryParseExact(value, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal); + } + private void RequireOwnedStagingPath(string path) { string parent = Path.GetDirectoryName(path) ?? string.Empty; string fileName = Path.GetFileName(path); if (!PathsEqual(parent, AppDirectory) - || !fileName.StartsWith(".client-staging-", StringComparison.Ordinal) - || !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _)) + || !HasCanonicalGuidName( + fileName, + ".client-staging-", + string.Empty)) { throw new LauncherUpdateException( "The client extraction path is not an owned LA10 staging directory."); @@ -901,7 +956,7 @@ public sealed class ClientVersionStore && !part.Any(character => char.IsControl(character) || character is '<' or '>' or '"' or '|' or '?' or '*') - && !IsWindowsDeviceName(part)); + && !PortablePathRules.IsWindowsDeviceName(part)); } internal static string ResolveContained(string root, string relative) @@ -929,19 +984,6 @@ public sealed class ClientVersionStore return path; } - private static bool IsWindowsDeviceName(string segment) - { - string stem = segment.Split('.')[0]; - return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) - || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) - || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) - || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) - || (stem.Length == 4 - && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) - || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) - && stem[3] is >= '1' and <= '9'); - } - private static bool PathsEqual(string left, string right) => string.Equals( Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs index 6470244a..e72bea60 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -8,16 +8,17 @@ public sealed record SelfUpdateStartupResult( string[] RemainingArguments); /// -/// Process-level rename dance for launcher self-update. Every child argument -/// is passed through with -/// UseShellExecute=false; no path or PID is ever interpolated into a -/// shell command. +/// Process-level self-update bootstrap. Every child argument is passed through +/// with shell execution disabled. /// public static class LauncherSelfUpdateBootstrap { public const string HelperArgument = "--acdream-self-update-helper-v1"; public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; + internal const string DeferredArgument = "--acdream-self-update-deferred-v1"; + internal const int DeferredLeaseExitCode = 73; private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); public static async Task HandleAsync( string[] args, @@ -32,10 +33,16 @@ public static class LauncherSelfUpdateBootstrap Path.GetFullPath(launcherBaseDirectory)); string executable = Path.GetFullPath(currentExecutablePath); + if (args.Length > 0 + && string.Equals(args[0], DeferredArgument, StringComparison.Ordinal)) + { + return new SelfUpdateStartupResult(false, 0, args[1..]); + } + if (args.Length > 0 && string.Equals(args[0], HelperArgument, StringComparison.Ordinal)) { - if (args.Length != 4 + if (args.Length < 4 || !int.TryParse( args[1], System.Globalization.NumberStyles.None, @@ -51,6 +58,7 @@ public static class LauncherSelfUpdateBootstrap parentPid, args[2], args[3], + args[4..], cancellationToken) .ConfigureAwait(false); return new SelfUpdateStartupResult(true, exitCode, []); @@ -59,7 +67,7 @@ public static class LauncherSelfUpdateBootstrap if (args.Length > 0 && string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal)) { - if (args.Length != 2) + if (args.Length < 2) { return new SelfUpdateStartupResult(true, 64, []); } @@ -70,82 +78,97 @@ public static class LauncherSelfUpdateBootstrap executable, cancellationToken) .ConfigureAwait(false); - return new SelfUpdateStartupResult(false, 0, []); - } - - SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) - .ConfigureAwait(false); - if (plan is null) - { - return new SelfUpdateStartupResult(false, 0, args); - } - - if (!PathsEqual(plan.TargetDirectory, baseDirectory)) - { - throw new LauncherUpdateException( - "The pending self-update targets a different launcher directory."); - } - - if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) - { - if (!manager.IsConfirmed(plan.TransactionId)) - { - await manager.ConfirmAsync( - plan.TransactionId, - baseDirectory, - executable, - cancellationToken) - .ConfigureAwait(false); - } - - await manager.CompleteConfirmedAsync( - plan.TransactionId, + await FinishConfirmedCleanupAsync( + manager, baseDirectory, cancellationToken) .ConfigureAwait(false); + return new SelfUpdateStartupResult(false, 0, args[2..]); + } + + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? startupLease)) + { + // A running session or another launcher is staging. Reading the + // plan is safe, but cleanup or starting a competing helper is not. return new SelfUpdateStartupResult(false, 0, args); } - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; - string expectedExecutable = ClientVersionStore.ResolveContained( - baseDirectory, - "acdream-launcher" + suffix); - if (!PathsEqual(executable, expectedExecutable)) + using (UpdateSessionBarrier.ExclusiveLease lease = startupLease + ?? throw new InvalidOperationException("Exclusive startup lease is missing.")) { - throw new LauncherUpdateException( - "Self-update can start only from the published acdream-launcher executable."); - } + SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + plan, + baseDirectory, + lease); + if (plan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } - string helperPath = manager.GetHelperPath(plan.TransactionId); - Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); - VerifiedArtifactDownloader.TryDelete(helperPath); - File.Copy(executable, helperPath, overwrite: false); - if (OperatingSystem.IsLinux()) - { - File.SetUnixFileMode( - helperPath, - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.UserExecute); - } + if (!PathsEqual(plan.TargetDirectory, baseDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update targets a different launcher directory."); + } - var startInfo = new ProcessStartInfo(helperPath) - { - UseShellExecute = false, - WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId), - }; - startInfo.ArgumentList.Add(HelperArgument); - startInfo.ArgumentList.Add( - Environment.ProcessId.ToString( - System.Globalization.CultureInfo.InvariantCulture)); - startInfo.ArgumentList.Add(baseDirectory); - startInfo.ArgumentList.Add(plan.TransactionId); - _ = Process.Start(startInfo) - ?? throw new LauncherUpdateException( - "The launcher self-update helper could not be started."); - return new SelfUpdateStartupResult(true, 0, []); + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + if (!manager.IsConfirmed(plan.TransactionId)) + { + await manager.ConfirmAsync( + plan.TransactionId, + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + plan.TransactionId, + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + pending: null, + baseDirectory, + lease); + return new SelfUpdateStartupResult(false, 0, args); + } + + string expectedExecutable = ClientVersionStore.ResolveContained( + baseDirectory, + GetLauncherFileName(plan.Rid)); + if (!PathsEqual(executable, expectedExecutable)) + { + throw new LauncherUpdateException( + "Self-update can start only from the published acdream-launcher executable."); + } + + string helperPath = manager.GetStagedLauncherPath(plan); + var startInfo = new ProcessStartInfo(helperPath) + { + UseShellExecute = false, + WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId), + }; + startInfo.ArgumentList.Add(HelperArgument); + startInfo.ArgumentList.Add( + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(baseDirectory); + startInfo.ArgumentList.Add(plan.TransactionId); + foreach (string argument in args) + { + startInfo.ArgumentList.Add(argument); + } + + _ = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The launcher self-update helper could not be started."); + return new SelfUpdateStartupResult(true, 0, []); + } } private static async Task RunHelperAsync( @@ -153,6 +176,7 @@ public static class LauncherSelfUpdateBootstrap int parentPid, string targetDirectory, string transactionId, + IReadOnlyList publicArguments, CancellationToken cancellationToken) { SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) @@ -164,12 +188,15 @@ public static class LauncherSelfUpdateBootstrap "The helper transaction does not match the pending self-update."); } - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; + if (!PathsEqual(plan.TargetDirectory, targetDirectory)) + { + throw new LauncherUpdateException( + "The helper target does not match the pending self-update."); + } + string launcherPath = ClientVersionStore.ResolveContained( targetDirectory, - "acdream-launcher" + suffix); + GetLauncherFileName(plan.Rid)); var startInfo = new ProcessStartInfo(launcherPath) { UseShellExecute = false, @@ -177,97 +204,184 @@ public static class LauncherSelfUpdateBootstrap }; startInfo.ArgumentList.Add(ConfirmArgument); startInfo.ArgumentList.Add(transactionId); - - Process? replacement = null; - UpdateSessionBarrier.ExclusiveLease? updateLease = null; - bool appliedByThisHelper = false; - try + foreach (string argument in publicArguments) { - await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); - updateLease = manager.Barrier.AcquireExclusive(); - plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) - .ConfigureAwait(false); - appliedByThisHelper = true; - replacement = Process.Start(startInfo) - ?? throw new LauncherUpdateException( - "The updated launcher could not be started."); - DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout; - while (!manager.IsConfirmed(transactionId)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline) - { - throw new LauncherUpdateException( - replacement.HasExited - ? $"The updated launcher exited with code {replacement.ExitCode} " - + "before confirming startup." - : "The updated launcher did not confirm startup in time."); - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - - await manager.CompleteConfirmedAsync( - transactionId, - targetDirectory, - cancellationToken) - .ConfigureAwait(false); - return 0; + startInfo.ArgumentList.Add(argument); } - catch + + await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? updateLease)) { - if (replacement is { HasExited: false }) + // Do not restart the canonical launcher: it would immediately see + // the same staged plan and create an unbounded helper loop. + return DeferredLeaseExitCode; + } + + using (UpdateSessionBarrier.ExclusiveLease lease = updateLease + ?? throw new InvalidOperationException("Exclusive update lease is missing.")) + { + plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException( + "The helper found no pending self-update after acquiring the lease."); + if (!string.Equals( + plan.TransactionId, + transactionId, + StringComparison.Ordinal) + || !PathsEqual(plan.TargetDirectory, targetDirectory)) { - replacement.Kill(entireProcessTree: true); - await replacement.WaitForExitAsync(CancellationToken.None) - .ConfigureAwait(false); + throw new LauncherUpdateException( + "The pending self-update changed before the helper acquired its lease."); } + _ = manager.CleanupOwnedResidueUnderLease( + plan, + targetDirectory, + lease); + Process? replacement = null; + bool appliedByThisHelper = false; try { - if (appliedByThisHelper) + plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) + .ConfigureAwait(false); + appliedByThisHelper = true; + replacement = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The updated launcher could not be started."); + DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout; + while (!manager.IsConfirmed(transactionId)) { - SelfUpdatePlan? pending = await manager.LoadPendingAsync( - CancellationToken.None) - .ConfigureAwait(false); - if (pending?.State == SelfUpdatePlanState.Applying) + cancellationToken.ThrowIfCancellationRequested(); + if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline) { - _ = await manager.RecoverApplyingAsync( - targetDirectory, - CancellationToken.None) - .ConfigureAwait(false); - } - else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) - { - _ = await manager.RollbackAwaitingConfirmationAsync( - targetDirectory, - CancellationToken.None) - .ConfigureAwait(false); + throw new LauncherUpdateException( + replacement.HasExited + ? $"The updated launcher exited with code {replacement.ExitCode} " + + "before confirming startup." + : "The updated launcher did not confirm startup in time."); } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); } + + await manager.CompleteConfirmedAsync( + transactionId, + targetDirectory, + cancellationToken) + .ConfigureAwait(false); + return 0; } catch { - // Do not start an executable from an ambiguous half-applied - // state. A subsequent startup replays the durable journal. - return 75; - } + if (replacement is { HasExited: false }) + { + replacement.Kill(entireProcessTree: true); + await replacement.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); + } - var restored = new ProcessStartInfo(launcherPath) + try + { + if (appliedByThisHelper) + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + if (pending?.State == SelfUpdatePlanState.Applying) + { + _ = await manager.RecoverApplyingAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) + { + _ = await manager.RollbackAwaitingConfirmationAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + } + } + catch + { + // An ambiguous state must not start either executable. + return 75; + } + + var restored = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + restored.ArgumentList.Add(DeferredArgument); + foreach (string argument in publicArguments) + { + restored.ArgumentList.Add(argument); + } + + _ = Process.Start(restored); + return 74; + } + finally { - UseShellExecute = false, - WorkingDirectory = Path.GetFullPath(targetDirectory), - }; - _ = Process.Start(restored); - return 74; - } - finally - { - replacement?.Dispose(); - updateLease?.Dispose(); + replacement?.Dispose(); + } } } + private static async Task FinishConfirmedCleanupAsync( + LauncherSelfUpdateManager manager, + string targetDirectory, + CancellationToken cancellationToken) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout; + do + { + cancellationToken.ThrowIfCancellationRequested(); + if (manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? lease)) + { + using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease + ?? throw new InvalidOperationException( + "Exclusive cleanup lease is missing.")) + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (pending is + { + State: SelfUpdatePlanState.AwaitingConfirmation, + } + && manager.IsConfirmed(pending.TransactionId)) + { + await manager.CompleteConfirmedAsync( + pending.TransactionId, + targetDirectory, + cancellationToken) + .ConfigureAwait(false); + pending = null; + } + + if (manager.CleanupOwnedResidueUnderLease( + pending, + targetDirectory, + acquiredLease)) + { + return; + } + } + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + while (DateTimeOffset.UtcNow < deadline); + } + + private static string GetLauncherFileName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + private static async Task WaitForParentExitAsync( int parentPid, CancellationToken cancellationToken) diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs index 1dddc952..71ef0064 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -11,7 +11,16 @@ public enum SelfUpdatePlanState AwaitingConfirmation, } -public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal); +public enum SelfUpdateApplyOperation +{ + Install, + Remove, +} + +public sealed record SelfUpdateApplyEntry( + string Path, + SelfUpdateApplyOperation Operation, + bool HadOriginal); public sealed record SelfUpdatePlan( int SchemaVersion, @@ -24,6 +33,15 @@ public sealed record SelfUpdatePlan( long ArchiveSize, IReadOnlyList Files, IReadOnlyList? Apply) +{ + public const int CurrentSchemaVersion = 2; +} + +public sealed record LauncherBinaryInstallRecord( + int SchemaVersion, + string Version, + string Rid, + IReadOnlyList Files) { public const int CurrentSchemaVersion = 1; } @@ -33,13 +51,27 @@ public sealed record SelfUpdateStageResult( string PendingPlanPath, string Status); +internal enum SelfUpdateApplyBoundary +{ + AfterTargetMutation, +} + +internal sealed record SelfUpdateApplyObservation( + SelfUpdateApplyBoundary Boundary, + string Path, + SelfUpdateApplyOperation Operation); + /// -/// Durable self-update transaction owner. It stages a verified launcher ZIP; -/// a separately copied helper performs move-only replacement after the parent -/// exits and can replay rollback after a crash at any file boundary. +/// Durable self-update transaction owner. Verified payload bytes are copied +/// into a target-local transaction before mutation. Existing targets use a +/// same-filesystem atomic replace with a target-local backup, so the canonical +/// executable is never absent at a durable boundary. /// public sealed class LauncherSelfUpdateManager { + public const string InstallRecordFileName = "launcher.install.json"; + private const string TargetTransactionPrefix = ".acdream-self-update-"; + private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -47,18 +79,34 @@ public sealed class LauncherSelfUpdateManager WriteIndented = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, MaxDepth = 32, - Converters = { new JsonStringEnumConverter( - JsonNamingPolicy.CamelCase, - allowIntegerValues: false) }, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, }; private readonly VerifiedArtifactDownloader _downloader; private readonly SafeZipExtractor _extractor; + private readonly Action? _applyObserver; public LauncherSelfUpdateManager( ApplicationPathSet paths, HttpClient httpClient, SafeZipExtractor? extractor = null) + : this(paths, httpClient, extractor, applyObserver: null) + { + } + + internal LauncherSelfUpdateManager( + ApplicationPathSet paths, + HttpClient httpClient, + SafeZipExtractor? extractor, + Action? applyObserver) { ArgumentNullException.ThrowIfNull(paths); RootDirectory = Path.Combine( @@ -70,6 +118,7 @@ public sealed class LauncherSelfUpdateManager _downloader = new VerifiedArtifactDownloader( httpClient ?? throw new ArgumentNullException(nameof(httpClient))); _extractor = extractor ?? new SafeZipExtractor(); + _applyObserver = applyObserver; } public string RootDirectory { get; } @@ -80,7 +129,7 @@ public sealed class LauncherSelfUpdateManager public UpdateSessionBarrier Barrier { get; } - public async Task StageAsync( + public Task StageAsync( ReleaseManifest manifest, string rid, string targetDirectory, @@ -88,15 +137,13 @@ public sealed class LauncherSelfUpdateManager CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(manifest); - ReleaseArtifact artifact = manifest.RequireLauncher(rid); - return await StageAsync( - manifest.Version, - rid, - artifact, - targetDirectory, - progress, - cancellationToken) - .ConfigureAwait(false); + return StageAsync( + manifest.Version, + rid, + manifest.RequireLauncher(rid), + targetDirectory, + progress, + cancellationToken); } internal async Task StageAsync( @@ -114,11 +161,13 @@ public sealed class LauncherSelfUpdateManager throw new ArgumentException("RID is invalid.", nameof(rid)); } + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); string target = NormalizeTargetDirectory(targetDirectory); Directory.CreateDirectory(RootDirectory); Directory.CreateDirectory(TransactionsDirectory); SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken) .ConfigureAwait(false); + _ = CleanupOwnedResidueUnderLease(existing, target, lease); if (existing is not null) { throw new LauncherUpdateException( @@ -148,6 +197,14 @@ public sealed class LauncherSelfUpdateManager extracted, rid, launcherPayload: true); + if (extracted.Any(file => string.Equals( + file.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + $"The launcher ZIP may not provide '{InstallRecordFileName}'."); + } var plan = new SelfUpdatePlan( SelfUpdatePlan.CurrentSchemaVersion, @@ -185,12 +242,15 @@ public sealed class LauncherSelfUpdateManager } } + /// + /// Reads the durable plan without mutating any transaction-owned path. + /// Cleanup is a separate operation that requires the exclusive OS lease. + /// public async Task LoadPendingAsync( CancellationToken cancellationToken = default) { if (!File.Exists(PendingPlanPath)) { - CleanupOwnedResidue(keepTransactionId: null); return null; } @@ -207,7 +267,6 @@ public sealed class LauncherSelfUpdateManager } ValidatePlan(plan, plan.TargetDirectory); - CleanupOwnedResidue(plan.TransactionId); return plan; } catch (OperationCanceledException) @@ -252,21 +311,17 @@ public sealed class LauncherSelfUpdateManager } await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); - var apply = new List(plan.Files.Count); - foreach (InstalledFileRecord file in plan.Files) - { - string targetPath = ClientVersionStore.ResolveContained( + LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync( expectedTarget, - file.Path); - if (Directory.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Self-update target '{file.Path}' is unexpectedly a directory."); - } - - apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath))); - } - + plan.Rid, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList apply = BuildApplyJournal( + plan, + previous, + expectedTarget); + await PrepareTargetTransactionAsync(plan, apply, cancellationToken) + .ConfigureAwait(false); plan = plan with { State = SelfUpdatePlanState.Applying, @@ -274,30 +329,16 @@ public sealed class LauncherSelfUpdateManager }; await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); - string payload = GetPayloadDirectory(plan.TransactionId); - string backup = GetBackupDirectory(plan.TransactionId); try { foreach (SelfUpdateApplyEntry entry in plan.Apply) { cancellationToken.ThrowIfCancellationRequested(); - string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); - string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path); - string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); - EnsureSafeParent(expectedTarget, targetPath); - Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); - if (entry.HadOriginal) - { - File.Move(targetPath, backupPath); - } - - File.Move(stagedPath, targetPath); - InstalledFileRecord file = plan.Files.Single(candidate => - string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal)); - if (OperatingSystem.IsLinux() && file.UnixMode != 0) - { - File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode); - } + ApplyEntry(plan, entry); + _applyObserver?.Invoke(new SelfUpdateApplyObservation( + SelfUpdateApplyBoundary.AfterTargetMutation, + entry.Path, + entry.Operation)); } plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation }; @@ -344,12 +385,9 @@ public sealed class LauncherSelfUpdateManager "The running launcher does not match the pending confirmation plan."); } - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; string expectedExecutable = ClientVersionStore.ResolveContained( expectedTarget, - "acdream-launcher" + suffix); + GetLauncherFileName(plan.Rid)); if (!PathsEqual(expectedExecutable, currentExecutablePath)) { throw new LauncherUpdateException( @@ -358,6 +396,11 @@ public sealed class LauncherSelfUpdateManager await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken) .ConfigureAwait(false); + await VerifyInstalledOwnershipMatchesPlanAsync( + plan, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); string confirmationPath = GetConfirmationPath(transactionId); await AtomicJsonFile.WriteBytesAsync( confirmationPath, @@ -377,7 +420,8 @@ public sealed class LauncherSelfUpdateManager SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) .ConfigureAwait(false) ?? throw new LauncherUpdateException("There is no self-update to complete."); - ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory)); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + ValidatePlan(plan, expectedTarget); if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) || plan.State != SelfUpdatePlanState.AwaitingConfirmation || !IsConfirmed(transactionId)) @@ -386,6 +430,7 @@ public sealed class LauncherSelfUpdateManager } File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan)); SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); } @@ -419,18 +464,211 @@ public sealed class LauncherSelfUpdateManager public string GetPayloadDirectory(string transactionId) => Path.Combine(GetTransactionDirectory(transactionId), "payload"); - public string GetBackupDirectory(string transactionId) => - Path.Combine(GetTransactionDirectory(transactionId), "backup"); - public string GetConfirmationPath(string transactionId) => Path.Combine(GetTransactionDirectory(transactionId), "confirmed"); - public string GetHelperPath(string transactionId) + internal string GetTargetTransactionDirectory(SelfUpdatePlan plan) => + Path.Combine(plan.TargetDirectory, TargetTransactionPrefix + plan.TransactionId); + + internal string GetStagedLauncherPath(SelfUpdatePlan plan) => + ClientVersionStore.ResolveContained( + GetPayloadDirectory(plan.TransactionId), + GetLauncherFileName(plan.Rid)); + + internal bool CleanupOwnedResidueUnderLease( + SelfUpdatePlan? pending, + string targetDirectory, + UpdateSessionBarrier.ExclusiveLease lease) { - string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; - return Path.Combine( - GetTransactionDirectory(transactionId), - "acdream-self-update-helper" + suffix); + Barrier.RequireOwned(lease); + string target = NormalizeTargetDirectory(targetDirectory); + CleanupDataResidue(pending?.TransactionId); + string? keepTarget = pending is + { + State: SelfUpdatePlanState.Applying or SelfUpdatePlanState.AwaitingConfirmation, + } + ? pending.TransactionId + : null; + CleanupTargetResidue(target, keepTarget); + return !HasReclaimableResidue(pending?.TransactionId, target, keepTarget); + } + + private static string GetLauncherFileName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + + private IReadOnlyList BuildApplyJournal( + SelfUpdatePlan plan, + LauncherBinaryInstallRecord? previous, + string targetDirectory) + { + var operations = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (InstalledFileRecord file in plan.Files) + { + operations.Add(file.Path, SelfUpdateApplyOperation.Install); + } + + operations.Add(InstallRecordFileName, SelfUpdateApplyOperation.Install); + if (previous is not null) + { + foreach (InstalledFileRecord file in previous.Files) + { + if (!operations.ContainsKey(file.Path)) + { + operations.Add(file.Path, SelfUpdateApplyOperation.Remove); + } + } + } + + var result = new List(operations.Count); + foreach ((string path, SelfUpdateApplyOperation operation) in operations + .OrderBy(item => item.Key, StringComparer.Ordinal)) + { + string targetPath = ClientVersionStore.ResolveContained(targetDirectory, path); + EnsureSafeParent(targetDirectory, targetPath); + if (Directory.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Self-update target '{path}' is unexpectedly a directory."); + } + + bool hadOriginal = File.Exists(targetPath); + if (operation == SelfUpdateApplyOperation.Remove && !hadOriginal) + { + throw new LauncherUpdateException( + $"Owned obsolete launcher file '{path}' is missing."); + } + + if (string.Equals( + path, + GetLauncherFileName(plan.Rid), + StringComparison.OrdinalIgnoreCase) + && !hadOriginal) + { + throw new LauncherUpdateException( + "The canonical launcher executable is missing before self-update."); + } + + result.Add(new SelfUpdateApplyEntry(path, operation, hadOriginal)); + } + + return result; + } + + private async Task PrepareTargetTransactionAsync( + SelfUpdatePlan plan, + IReadOnlyList apply, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + if (Directory.Exists(swap)) + { + ClientVersionStore.RejectReparseTree(swap); + SafeZipExtractor.TryDeleteDirectory(swap); + } + + if (Directory.Exists(swap) || File.Exists(swap)) + { + throw new LauncherUpdateException( + "The target-local self-update transaction could not be reclaimed."); + } + + string incoming = Path.Combine(swap, "incoming"); + Directory.CreateDirectory(incoming); + string payload = GetPayloadDirectory(plan.TransactionId); + foreach (InstalledFileRecord file in plan.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string source = ClientVersionStore.ResolveContained(payload, file.Path); + string destination = ClientVersionStore.ResolveContained(incoming, file.Path); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await CopyFileDurablyAsync(source, destination, cancellationToken) + .ConfigureAwait(false); + if (OperatingSystem.IsLinux() && file.UnixMode != 0) + { + File.SetUnixFileMode(destination, (UnixFileMode)file.UnixMode); + } + + await VerifyFileAsync( + incoming, + file, + "Target-local incoming launcher", + cancellationToken) + .ConfigureAwait(false); + } + + var ownership = new LauncherBinaryInstallRecord( + LauncherBinaryInstallRecord.CurrentSchemaVersion, + plan.Version, + plan.Rid, + plan.Files); + await AtomicJsonFile.WriteAsync( + Path.Combine(incoming, InstallRecordFileName), + ownership, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + ClientVersionStore.RejectReparseTree(swap); + + string[] actual = Directory.EnumerateFiles( + incoming, + "*", + SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(incoming, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] expected = apply + .Where(entry => entry.Operation == SelfUpdateApplyOperation.Install) + .Select(entry => entry.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The target-local self-update incoming tree is incomplete."); + } + } + + private void ApplyEntry(SelfUpdatePlan plan, SelfUpdateApplyEntry entry) + { + string swap = GetTargetTransactionDirectory(plan); + string incoming = Path.Combine(swap, "incoming"); + string backup = Path.Combine(swap, "backup"); + string targetPath = ClientVersionStore.ResolveContained( + plan.TargetDirectory, + entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + EnsureSafeParent(plan.TargetDirectory, targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + if (!entry.HadOriginal || !File.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Owned obsolete launcher file '{entry.Path}' vanished during apply."); + } + + File.Move(targetPath, backupPath); + return; + } + + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + if (!File.Exists(incomingPath)) + { + throw new LauncherUpdateException( + $"Incoming launcher file '{entry.Path}' is missing."); + } + + if (entry.HadOriginal) + { + File.Replace(incomingPath, targetPath, backupPath, ignoreMetadataErrors: true); + } + else + { + File.Move(incomingPath, targetPath); + } } private async Task RollbackApplyingAsync( @@ -442,26 +680,52 @@ public sealed class LauncherSelfUpdateManager throw new LauncherUpdateException("The self-update rollback journal is missing."); } - string payload = GetPayloadDirectory(plan.TransactionId); - string backup = GetBackupDirectory(plan.TransactionId); + string swap = GetTargetTransactionDirectory(plan); + string backup = Path.Combine(swap, "backup"); + string discard = Path.Combine(swap, "rollback-discard"); foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse()) { cancellationToken.ThrowIfCancellationRequested(); - string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path); string targetPath = ClientVersionStore.ResolveContained( plan.TargetDirectory, entry.Path); string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); - if (!File.Exists(stagedPath) && File.Exists(targetPath)) + if (entry.Operation == SelfUpdateApplyOperation.Remove) { - Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!); - File.Move(targetPath, stagedPath); + if (File.Exists(backupPath)) + { + if (File.Exists(targetPath) || Directory.Exists(targetPath)) + { + throw new LauncherUpdateException( + $"Obsolete launcher rollback target '{entry.Path}' was recreated."); + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Move(backupPath, targetPath); + } + + continue; } if (entry.HadOriginal && File.Exists(backupPath)) { Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - File.Move(backupPath, targetPath); + if (File.Exists(targetPath)) + { + string discardPath = ClientVersionStore.ResolveContained( + discard, + entry.Path); + Directory.CreateDirectory(Path.GetDirectoryName(discardPath)!); + File.Replace( + backupPath, + targetPath, + discardPath, + ignoreMetadataErrors: true); + } + else + { + File.Move(backupPath, targetPath); + } } else if (!entry.HadOriginal && File.Exists(targetPath)) { @@ -469,7 +733,7 @@ public sealed class LauncherSelfUpdateManager } } - SafeZipExtractor.TryDeleteDirectory(backup); + SafeZipExtractor.TryDeleteDirectory(swap); plan = plan with { State = SelfUpdatePlanState.Staged, @@ -510,24 +774,8 @@ public sealed class LauncherSelfUpdateManager foreach (InstalledFileRecord file in plan.Files) { - cancellationToken.ThrowIfCancellationRequested(); - string path = ClientVersionStore.ResolveContained(payload, file.Path); - var info = new FileInfo(path); - if (!info.Exists || info.Length != file.Size) - { - throw new LauncherUpdateException( - $"Staged launcher file '{file.Path}' size is corrupt."); - } - - string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( - path, - cancellationToken) + await VerifyFileAsync(payload, file, "Staged launcher", cancellationToken) .ConfigureAwait(false); - if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) - { - throw new LauncherUpdateException( - $"Staged launcher file '{file.Path}' SHA-256 is corrupt."); - } } } @@ -538,37 +786,121 @@ public sealed class LauncherSelfUpdateManager { foreach (InstalledFileRecord file in plan.Files) { - cancellationToken.ThrowIfCancellationRequested(); - string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path); - EnsureSafeParent(targetDirectory, path); - var info = new FileInfo(path); - if (!info.Exists - || (info.Attributes & FileAttributes.ReparsePoint) != 0 - || info.Length != file.Size) - { - throw new LauncherUpdateException( - $"Applied launcher file '{file.Path}' is missing, linked, or corrupt."); - } - - string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( - path, + await VerifyFileAsync( + targetDirectory, + file, + "Applied launcher", cancellationToken) .ConfigureAwait(false); - if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) - { - throw new LauncherUpdateException( - $"Applied launcher file '{file.Path}' SHA-256 is corrupt."); - } + } - if (OperatingSystem.IsLinux() - && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + if (plan.Apply is not null) + { + foreach (SelfUpdateApplyEntry obsolete in plan.Apply.Where(entry => + entry.Operation == SelfUpdateApplyOperation.Remove)) { - throw new LauncherUpdateException( - $"Applied launcher file '{file.Path}' mode is corrupt."); + string path = ClientVersionStore.ResolveContained( + targetDirectory, + obsolete.Path); + if (File.Exists(path) || Directory.Exists(path)) + { + throw new LauncherUpdateException( + $"Obsolete launcher file '{obsolete.Path}' remains after apply."); + } } } } + private static async Task VerifyFileAsync( + string root, + InstalledFileRecord file, + string description, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ClientVersionStore.ResolveContained(root, file.Path); + EnsureSafeParent(root, path); + var info = new FileInfo(path); + if (!info.Exists + || (info.Attributes & FileAttributes.ReparsePoint) != 0 + || info.Length != file.Size) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' is missing, linked, or corrupt."); + } + + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' mode is corrupt."); + } + } + + private static async Task ReadAndVerifyInstallRecordAsync( + string targetDirectory, + string rid, + CancellationToken cancellationToken) + { + string path = Path.Combine(targetDirectory, InstallRecordFileName); + if (!File.Exists(path)) + { + return null; + } + + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException("The launcher ownership record is linked."); + } + + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + LauncherBinaryInstallRecord? record = ClientVersionStore.ParseStrict( + bytes, + SerializerOptions); + ValidateInstallRecord(record, rid); + foreach (InstalledFileRecord file in record!.Files) + { + await VerifyFileAsync( + targetDirectory, + file, + "Owned launcher", + cancellationToken) + .ConfigureAwait(false); + } + + return record; + } + + private static async Task VerifyInstalledOwnershipMatchesPlanAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + LauncherBinaryInstallRecord? record = await ReadAndVerifyInstallRecordAsync( + targetDirectory, + plan.Rid, + cancellationToken) + .ConfigureAwait(false); + if (record is null + || !string.Equals(record.Version, plan.Version, StringComparison.Ordinal) + || !record.Files.SequenceEqual(plan.Files)) + { + throw new LauncherUpdateException( + "The installed launcher ownership record does not match the pending plan."); + } + } + private async Task WritePlanAsync( SelfUpdatePlan plan, CancellationToken cancellationToken) @@ -607,37 +939,15 @@ public sealed class LauncherSelfUpdateManager "The self-update target does not match the running launcher directory."); } - if (plan.Files is null || plan.Files.Count == 0) - { - throw new LauncherUpdateException("The self-update file list is empty."); - } - - var paths = new HashSet(StringComparer.OrdinalIgnoreCase); - string? prior = null; - foreach (InstalledFileRecord file in plan.Files) - { - if (!ClientVersionStore.IsNormalizedRelative(file.Path) - || !paths.Add(file.Path) - || !ReleaseManifestClient.IsSha256(file.Sha256) - || file.Size < 0 - || file.UnixMode is < 0 or > 0x1FF - || (prior is not null - && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) - { - throw new LauncherUpdateException( - "The self-update file list is invalid, duplicated, or unsorted."); - } - - prior = file.Path; - } - - string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) - ? ".exe" - : string.Empty; - if (!paths.Contains("acdream-launcher" + suffix)) + ValidateFileRecords(plan.Files, "self-update file list"); + var newPaths = new HashSet( + plan.Files.Select(file => file.Path), + StringComparer.OrdinalIgnoreCase); + if (newPaths.Contains(InstallRecordFileName) + || !newPaths.Contains(GetLauncherFileName(plan.Rid))) { throw new LauncherUpdateException( - "The self-update plan lacks the launcher root executable."); + "The self-update file list has a reserved path or lacks the launcher executable."); } if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null @@ -649,22 +959,137 @@ public sealed class LauncherSelfUpdateManager if (plan.Apply is not null) { - if (plan.Apply.Count != plan.Files.Count - || !plan.Apply.Select(entry => entry.Path) - .SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal)) + var applyPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + if (!ClientVersionStore.IsNormalizedRelative(entry.Path) + || !applyPaths.Add(entry.Path) + || !Enum.IsDefined(entry.Operation) + || (entry.Operation == SelfUpdateApplyOperation.Remove + && !entry.HadOriginal) + || (prior is not null + && string.Compare(prior, entry.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + "The self-update apply journal is invalid, duplicated, or unsorted."); + } + + prior = entry.Path; + } + + foreach (string required in newPaths.Append(InstallRecordFileName)) + { + SelfUpdateApplyEntry? entry = plan.Apply.FirstOrDefault(candidate => + string.Equals(candidate.Path, required, StringComparison.OrdinalIgnoreCase)); + if (entry?.Operation != SelfUpdateApplyOperation.Install) + { + throw new LauncherUpdateException( + "The self-update apply journal does not install every new owned file."); + } + } + + if (plan.Apply.Any(entry => + entry.Operation == SelfUpdateApplyOperation.Remove + && (newPaths.Contains(entry.Path) + || string.Equals( + entry.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase)))) { throw new LauncherUpdateException( - "The self-update apply journal does not match the file list."); + "The self-update journal removes a new or reserved file."); } } string transactionDirectory = GetTransactionDirectory(plan.TransactionId); - if (!IsContained(TransactionsDirectory, transactionDirectory)) + if (!IsContained(TransactionsDirectory, transactionDirectory) + || !IsContained(target, GetTargetTransactionDirectory(plan))) { - throw new LauncherUpdateException("The self-update transaction path escaped."); + throw new LauncherUpdateException("A self-update transaction path escaped."); } } + private static void ValidateInstallRecord(LauncherBinaryInstallRecord? record, string rid) + { + if (record is null + || record.SchemaVersion != LauncherBinaryInstallRecord.CurrentSchemaVersion + || !LauncherVersion.TryParse(record.Version, out _) + || !string.Equals(record.Rid, rid, StringComparison.Ordinal)) + { + throw new LauncherUpdateException("The launcher ownership record is invalid."); + } + + ValidateFileRecords(record.Files, "launcher ownership file list"); + if (record.Files.Any(file => string.Equals( + file.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase)) + || !record.Files.Any(file => string.Equals( + file.Path, + GetLauncherFileName(rid), + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + "The launcher ownership record contains its reserved metadata path " + + "or lacks the canonical launcher executable."); + } + } + + private static void ValidateFileRecords( + IReadOnlyList? files, + string description) + { + if (files is null || files.Count == 0) + { + throw new LauncherUpdateException($"The {description} is empty."); + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in files) + { + if (!ClientVersionStore.IsNormalizedRelative(file.Path) + || !paths.Add(file.Path) + || !ReleaseManifestClient.IsSha256(file.Sha256) + || file.Size < 0 + || file.UnixMode is < 0 or > 0x1FF + || (prior is not null + && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + $"The {description} is invalid, duplicated, or unsorted."); + } + + prior = file.Path; + } + } + + private static async Task CopyFileDurablyAsync( + string source, + string destination, + CancellationToken cancellationToken) + { + await using var input = new FileStream( + source, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream( + destination, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await input.CopyToAsync(output, 64 * 1024, cancellationToken) + .ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + private static string NormalizeTargetDirectory(string targetDirectory) { ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory); @@ -741,7 +1166,7 @@ public sealed class LauncherSelfUpdateManager } } - private void CleanupOwnedResidue(string? keepTransactionId) + private void CleanupDataResidue(string? keepTransactionId) { if (Directory.Exists(TransactionsDirectory)) { @@ -751,12 +1176,7 @@ public sealed class LauncherSelfUpdateManager SearchOption.TopDirectoryOnly)) { string name = Path.GetFileName(directory); - if (name.Length == 32 - && Guid.TryParseExact(name, "N", out Guid transaction) - && string.Equals( - transaction.ToString("N"), - name, - StringComparison.Ordinal) + if (IsCanonicalTransactionId(name) && !string.Equals(name, keepTransactionId, StringComparison.Ordinal)) { SafeZipExtractor.TryDeleteDirectory(directory); @@ -775,12 +1195,72 @@ public sealed class LauncherSelfUpdateManager SearchOption.TopDirectoryOnly)) { string name = Path.GetFileName(temporary); - string prefix = ".pending.json."; - string transaction = name[prefix.Length..^".tmp".Length]; - if (Guid.TryParseExact(transaction, "N", out _)) + const string prefix = ".pending.json."; + const string suffix = ".tmp"; + if (name.Length == prefix.Length + 32 + suffix.Length + && name.StartsWith(prefix, StringComparison.Ordinal) + && name.EndsWith(suffix, StringComparison.Ordinal) + && IsCanonicalTransactionId(name.Substring(prefix.Length, 32))) { VerifiedArtifactDownloader.TryDelete(temporary); } } } + + private static void CleanupTargetResidue( + string targetDirectory, + string? keepTransactionId) + { + foreach (string directory in Directory.EnumerateDirectories( + targetDirectory, + TargetTransactionPrefix + "*", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(directory); + string transaction = name[TargetTransactionPrefix.Length..]; + if (name.Length == TargetTransactionPrefix.Length + 32 + && IsCanonicalTransactionId(transaction) + && !string.Equals(transaction, keepTransactionId, StringComparison.Ordinal)) + { + SafeZipExtractor.TryDeleteDirectory(directory); + } + } + } + + private bool HasReclaimableResidue( + string? keepDataTransactionId, + string targetDirectory, + string? keepTargetTransactionId) + { + bool data = Directory.Exists(TransactionsDirectory) + && Directory.EnumerateDirectories( + TransactionsDirectory, + "*", + SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(name => name is not null + && IsCanonicalTransactionId(name) + && !string.Equals( + name, + keepDataTransactionId, + StringComparison.Ordinal)); + bool target = Directory.EnumerateDirectories( + targetDirectory, + TargetTransactionPrefix + "*", + SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(name => name is not null + && name.Length == TargetTransactionPrefix.Length + 32 + && IsCanonicalTransactionId(name[TargetTransactionPrefix.Length..]) + && !string.Equals( + name[TargetTransactionPrefix.Length..], + keepTargetTransactionId, + StringComparison.Ordinal)); + return data || target; + } + + private static bool IsCanonicalTransactionId(string value) => + value.Length == 32 + && Guid.TryParseExact(value, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal); } diff --git a/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs index 810ac57c..01d7676b 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs @@ -292,9 +292,6 @@ public sealed class LauncherUpdater : ILauncherUpdater try { RefuseRunningSessions(); - using UpdateSessionBarrier.ExclusiveLease lease = - _versions.Barrier.AcquireExclusive(); - RefuseRunningSessions(); if (check.Manifest.Version <= _launcherVersion) { throw new LauncherUpdateException( diff --git a/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs b/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs new file mode 100644 index 00000000..1f5ec179 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs @@ -0,0 +1,38 @@ +namespace AcDream.Launcher.Core.Updates; + +/// +/// Host-independent path rules for payloads that must remain safe when moved +/// between Linux and Windows. Windows device aliases are rejected on every +/// host so a release cannot verify on one platform and become ambiguous on +/// another. +/// +internal static class PortablePathRules +{ + public static bool IsWindowsDeviceName(string segment) + { + ArgumentNullException.ThrowIfNull(segment); + string stem = segment.Split('.')[0]; + if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CONIN$", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CONOUT$", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (stem.Length != 4 + || (!stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + && !stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return stem[3] is >= '1' and <= '9' + or '\u00b9' + or '\u00b2' + or '\u00b3'; + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index 6718ddcb..a31625c1 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -10,9 +10,11 @@ public interface IReleaseManifestClient } /// -/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is -/// accepted only for a loopback fixture; production and artifact URLs are -/// HTTPS-only. +/// Strict, bounded reader for the pinned GitHub Releases manifest. Production +/// construction is HTTPS-only. The loopback HTTP allowance is available only +/// through an internal fixture factory and is never inferred from a URI. +/// Redirects are followed manually so every hop is checked before any bytes +/// cross that hop. /// public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable { @@ -20,6 +22,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable public const string GitHubRepository = "acdream"; public const int MaximumManifestBytes = 1024 * 1024; public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024; + public const int MaximumRedirects = 5; public static Uri ProductionManifestUri { get; } = new( $"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json"); @@ -33,65 +36,103 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable }; private readonly HttpClient _httpClient; - private readonly bool _ownsHttpClient; private readonly Uri _manifestUri; + private readonly bool _allowLoopbackHttp; - public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null) + public ReleaseManifestClient(TimeSpan? timeout = null) + : this( + ProductionManifestUri, + allowLoopbackHttp: false, + CreateRedirectDisabledHandler(), + timeout) { - _httpClient = httpClient ?? new HttpClient(); - _ownsHttpClient = httpClient is null; - _manifestUri = manifestUri ?? ProductionManifestUri; - RequireSecureOrLoopback(_manifestUri, "manifest"); - if (_ownsHttpClient) - { - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); - } } + private ReleaseManifestClient( + Uri manifestUri, + bool allowLoopbackHttp, + HttpMessageHandler handler, + TimeSpan? timeout) + { + ArgumentNullException.ThrowIfNull(manifestUri); + ArgumentNullException.ThrowIfNull(handler); + _manifestUri = manifestUri; + _allowLoopbackHttp = allowLoopbackHttp; + RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp); + _httpClient = new HttpClient(handler, disposeHandler: true) + { + Timeout = timeout ?? TimeSpan.FromSeconds(15), + }; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + } + + internal static ReleaseManifestClient CreateLoopbackFixture( + Uri manifestUri, + TimeSpan? timeout = null) => new( + manifestUri, + allowLoopbackHttp: true, + CreateRedirectDisabledHandler(), + timeout); + + internal static ReleaseManifestClient CreateForTransportTest( + Uri manifestUri, + bool allowLoopbackHttp, + HttpMessageHandler handler) => new( + manifestUri, + allowLoopbackHttp, + handler, + TimeSpan.FromSeconds(15)); + public async Task FetchAsync( CancellationToken cancellationToken = default) { try { - using HttpResponseMessage response = await _httpClient.GetAsync( - _manifestUri, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken) - .ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri; - RequireSecureOrLoopback(finalUri, "manifest redirect"); - if (response.Content.Headers.ContentLength is long contentLength - && contentLength > MaximumManifestBytes) + Uri current = _manifestUri; + var visited = new HashSet(StringComparer.Ordinal); + for (int redirectCount = 0;;) { - throw new LauncherUpdateException( - $"The release manifest is larger than {MaximumManifestBytes} bytes."); - } - - await using Stream input = await response.Content - .ReadAsStreamAsync(cancellationToken) - .ConfigureAwait(false); - using var output = new MemoryStream(); - byte[] buffer = new byte[16 * 1024]; - while (true) - { - int read = await input.ReadAsync(buffer, cancellationToken) - .ConfigureAwait(false); - if (read == 0) - { - break; - } - - if (output.Length + read > MaximumManifestBytes) + RequireTransport(current, "manifest redirect", _allowLoopbackHttp); + if (!visited.Add(current.AbsoluteUri)) { throw new LauncherUpdateException( - $"The release manifest is larger than {MaximumManifestBytes} bytes."); + "The release manifest redirect chain contains a loop."); } - output.Write(buffer, 0, read); - } + using var request = new HttpRequestMessage(HttpMethod.Get, current); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + if (IsRedirect(response.StatusCode)) + { + if (redirectCount >= MaximumRedirects) + { + throw new LauncherUpdateException( + $"The release manifest exceeded {MaximumRedirects} redirects."); + } - return Parse(output.ToArray()); + Uri? location = response.Headers.Location; + if (location is null) + { + throw new LauncherUpdateException( + "The release manifest redirect has no Location header."); + } + + Uri next = location.IsAbsoluteUri + ? location + : new Uri(current, location); + RequireTransport(next, "manifest redirect", _allowLoopbackHttp); + current = next; + redirectCount++; + continue; + } + + response.EnsureSuccessStatusCode(); + return await ReadAndParseAsync(response, cancellationToken) + .ConfigureAwait(false); + } } catch (OperationCanceledException) { @@ -112,7 +153,9 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable } } - internal static ReleaseManifest Parse(ReadOnlySpan utf8) + internal static ReleaseManifest Parse( + ReadOnlySpan utf8, + bool allowLoopbackHttpArtifacts = false) { try { @@ -127,7 +170,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable RejectDuplicateProperties(document.RootElement, "$" ); ManifestDocument? value = document.RootElement.Deserialize( SerializerOptions); - return Validate(value); + return Validate(value, allowLoopbackHttpArtifacts); } catch (LauncherUpdateException) { @@ -143,26 +186,68 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable } } - public void Dispose() - { - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } + public void Dispose() => _httpClient.Dispose(); - internal static void RequireSecureOrLoopback(Uri uri, string description) + internal static void RequireTransport( + Uri uri, + string description, + bool allowLoopbackHttp) { if (!uri.IsAbsoluteUri || (uri.Scheme != Uri.UriSchemeHttps - && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) + && !(allowLoopbackHttp + && uri.Scheme == Uri.UriSchemeHttp + && uri.IsLoopback))) { throw new LauncherUpdateException( - $"The {description} URI must use HTTPS (loopback HTTP is test-only)."); + $"The {description} URI must use HTTPS" + + (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : ".")); } } - private static ReleaseManifest Validate(ManifestDocument? document) + internal static void RequireSecureOrLoopback(Uri uri, string description) => + RequireTransport(uri, description, allowLoopbackHttp: true); + + private async Task ReadAndParseAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength is long contentLength + && contentLength > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + await using Stream input = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var output = new MemoryStream(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await input.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (output.Length + read > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + output.Write(buffer, 0, read); + } + + return Parse(output.ToArray(), _allowLoopbackHttp); + } + + private static ReleaseManifest Validate( + ManifestDocument? document, + bool allowLoopbackHttpArtifacts) { if (document is null) { @@ -187,18 +272,22 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable throw new LauncherUpdateException( "The minimum launcher version cannot exceed the release version."); } + IReadOnlyDictionary clients = ValidateArtifacts( document.Clients, - "clients"); + "clients", + allowLoopbackHttpArtifacts); IReadOnlyDictionary launchers = ValidateArtifacts( document.Launchers, - "launchers"); + "launchers", + allowLoopbackHttpArtifacts); return new ReleaseManifest(version, minimum, clients, launchers); } private static IReadOnlyDictionary ValidateArtifacts( Dictionary? artifacts, - string field) + string field, + bool allowLoopbackHttpArtifacts) { if (artifacts is null || artifacts.Count == 0) { @@ -226,7 +315,10 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable $"Manifest payload '{field}.{rid}' has an invalid URL."); } - RequireSecureOrLoopback(uri, $"{field}.{rid} artifact"); + RequireTransport( + uri, + $"{field}.{rid} artifact", + allowLoopbackHttpArtifacts); if (!IsSha256(value.Sha256)) { throw new LauncherUpdateException( @@ -250,6 +342,21 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable internal static bool IsSha256(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit); + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + + private static HttpMessageHandler CreateRedirectDisabledHandler() => + new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.None, + }; + private static void RejectDuplicateProperties(JsonElement element, string path) { if (element.ValueKind == JsonValueKind.Object) diff --git a/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs index 6d2c77ee..e7a39b5f 100644 --- a/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs +++ b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs @@ -297,7 +297,7 @@ public sealed class SafeZipExtractor || segment.Any(character => char.IsControl(character) || character is '<' or '>' or '"' or '|' or '?' or '*') - || IsWindowsDeviceName(segment)) + || PortablePathRules.IsWindowsDeviceName(segment)) { throw new LauncherUpdateException( $"ZIP path '{name}' contains an unsafe segment."); @@ -396,19 +396,6 @@ public sealed class SafeZipExtractor } } - private static bool IsWindowsDeviceName(string segment) - { - string stem = segment.Split('.')[0]; - return stem.Equals("CON", StringComparison.OrdinalIgnoreCase) - || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) - || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) - || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) - || (stem.Length == 4 - && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) - || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) - && stem[3] is >= '1' and <= '9'); - } - internal static void TryDeleteDirectory(string path) { try diff --git a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs index 6c73ff35..1ea640fb 100644 --- a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -34,7 +34,53 @@ public sealed class UpdateSessionBarrier FileShare.None, "A launcher session or another update transaction is running. " + "Stop every launcher session before updating."); - return new ExclusiveLease(stream); + return new ExclusiveLease(this, stream); + } + + /// + /// Non-blocking startup probe. Contention is an expected "not now" + /// result; permission and path failures remain hard errors. + /// + public bool TryAcquireExclusive(out ExclusiveLease? lease) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + lease = new ExclusiveLease( + this, + new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None)); + return true; + } + catch (IOException) + { + lease = null; + return false; + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + + internal void RequireOwned(ExclusiveLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!lease.IsHeldBy(this)) + { + throw new LauncherUpdateException( + "The cleanup operation does not hold this update barrier's exclusive lease."); + } } private FileStream Open(FileShare share, string refusal) @@ -76,9 +122,18 @@ public sealed class UpdateSessionBarrier public sealed class ExclusiveLease : IDisposable { + private readonly UpdateSessionBarrier _owner; private FileStream? _stream; - internal ExclusiveLease(FileStream stream) => _stream = stream; + internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream) + { + _owner = owner; + _stream = stream; + } + + internal bool IsHeldBy(UpdateSessionBarrier owner) => + ReferenceEquals(_owner, owner) + && Volatile.Read(ref _stream) is not null; public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); } diff --git a/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs index 608207ff..6589d102 100644 --- a/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs +++ b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Net; using System.Security.Cryptography; namespace AcDream.Launcher.Core.Updates; @@ -55,15 +56,11 @@ public sealed class VerifiedArtifactDownloader bool ownsDestination = false; try { - using HttpResponseMessage response = await _httpClient.GetAsync( + using HttpResponseMessage response = await SendWithValidatedRedirectsAsync( artifact.Url, - HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - ReleaseManifestClient.RequireSecureOrLoopback( - response.RequestMessage?.RequestUri ?? artifact.Url, - "artifact redirect"); if (response.Content.Headers.ContentLength is long contentLength && contentLength != artifact.Size) { @@ -183,6 +180,86 @@ public sealed class VerifiedArtifactDownloader } } + private async Task SendWithValidatedRedirectsAsync( + Uri initialUri, + CancellationToken cancellationToken) + { + bool allowLoopbackHttp = initialUri.Scheme == Uri.UriSchemeHttp + && initialUri.IsLoopback; + Uri current = initialUri; + var visited = new HashSet(StringComparer.Ordinal); + for (int redirectCount = 0;;) + { + ReleaseManifestClient.RequireTransport( + current, + "artifact redirect", + allowLoopbackHttp); + if (!visited.Add(current.AbsoluteUri)) + { + throw new LauncherUpdateException( + "The release artifact redirect chain contains a loop."); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, current); + HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + Uri effectiveUri = response.RequestMessage?.RequestUri ?? current; + if (!Uri.Equals(effectiveUri, current)) + { + response.Dispose(); + throw new LauncherUpdateException( + "The artifact HTTP transport followed an automatic redirect; " + + "every redirect must be validated before it is requested."); + } + + if (!IsRedirect(response.StatusCode)) + { + return response; + } + + try + { + if (redirectCount >= ReleaseManifestClient.MaximumRedirects) + { + throw new LauncherUpdateException( + $"The release artifact exceeded " + + $"{ReleaseManifestClient.MaximumRedirects} redirects."); + } + + Uri? location = response.Headers.Location; + if (location is null) + { + throw new LauncherUpdateException( + "The release artifact redirect has no Location header."); + } + + Uri next = location.IsAbsoluteUri + ? location + : new Uri(current, location); + ReleaseManifestClient.RequireTransport( + next, + "artifact redirect", + allowLoopbackHttp); + current = next; + redirectCount++; + } + finally + { + response.Dispose(); + } + } + } + + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + internal static void TryDelete(string path) { try diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 63fa57ff..350260b2 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -14,6 +14,10 @@ true + + + + diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index dae76b75..c42f0de0 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -16,8 +16,7 @@ public sealed partial class App : Application { private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; - private HttpClient? _updateHttpClient; - private ReleaseManifestClient? _manifestClient; + private LauncherUpdateComposition? _updateComposition; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -52,38 +51,27 @@ public sealed partial class App : Application $"Client content verification failed: {ex.Message}"); } - var clientVersions = new ClientVersionStore(paths); - _ = clientVersions.LoadAndRecoverAsync(rid) - .GetAwaiter() - .GetResult(); + LauncherUpdateComposition updates = LauncherUpdateComposition.Create( + paths, + rid, + GetLauncherVersion(), + AppContext.BaseDirectory, + () => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive) + == true); + _updateComposition = updates; _orchestrator = new LauncherOrchestrator( profiles, paths, - LauncherExecutableSet.FromCurrentVersionStore(clientVersions), + updates.Executables, verification.Record, installationStatus: verification.Status, - updateSessionBarrier: clientVersions.Barrier); - _updateHttpClient = new HttpClient(); - _updateHttpClient.Timeout = TimeSpan.FromSeconds(15); - _updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd( - "acdream-launcher/1"); - _manifestClient = new ReleaseManifestClient(_updateHttpClient); - var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient); - var updater = new LauncherUpdater( - _manifestClient, - _updateHttpClient, - clientVersions, - selfUpdates, - GetLauncherVersion(), - rid, - AppContext.BaseDirectory, - () => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive)); + updateSessionBarrier: updates.Versions.Barrier); _viewModel = new LauncherWindowViewModel( _orchestrator, new AvaloniaUiDispatcher(), installer, - updater); + updates.Updater); _viewModel.Initialize(); desktop.MainWindow = new MainWindow @@ -100,12 +88,10 @@ public sealed partial class App : Application { _viewModel?.Dispose(); _orchestrator?.Dispose(); - _manifestClient?.Dispose(); - _updateHttpClient?.Dispose(); + _updateComposition?.Dispose(); _viewModel = null; _orchestrator = null; - _manifestClient = null; - _updateHttpClient = null; + _updateComposition = null; } private static LauncherVersion GetLauncherVersion() diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs new file mode 100644 index 00000000..3b395042 --- /dev/null +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Security; +using System.Text.Json; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Updates; +using AcDream.Launcher.ViewModels; +using AcDream.Platform; + +namespace AcDream.Launcher; + +/// +/// Testable startup transaction for versioned-client/update services. Storage +/// failures produce a fail-closed executable resolver and an unavailable UI +/// projection; they do not abort profile/installer window construction. +/// +internal sealed class LauncherUpdateComposition : IDisposable +{ + private readonly HttpClient? _artifactClient; + private readonly ReleaseManifestClient? _manifestClient; + + private LauncherUpdateComposition( + ClientVersionStore versions, + LauncherExecutableSet executables, + ILauncherUpdater updater, + HttpClient? artifactClient, + ReleaseManifestClient? manifestClient) + { + Versions = versions; + Executables = executables; + Updater = updater; + _artifactClient = artifactClient; + _manifestClient = manifestClient; + } + + public ClientVersionStore Versions { get; } + + public LauncherExecutableSet Executables { get; } + + public ILauncherUpdater Updater { get; } + + public static LauncherUpdateComposition Create( + ApplicationPathSet paths, + string rid, + LauncherVersion launcherVersion, + string launcherTargetDirectory, + Func hasRunningSessions, + Func? initialize = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(launcherVersion); + ArgumentNullException.ThrowIfNull(hasRunningSessions); + var versions = new ClientVersionStore(paths); + HttpClient? artifactClient = null; + ReleaseManifestClient? manifestClient = null; + try + { + _ = initialize is null + ? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult() + : initialize(versions, rid); + artifactClient = new HttpClient( + new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.None, + }, + disposeHandler: true) + { + Timeout = TimeSpan.FromSeconds(15), + }; + artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15)); + var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient); + var updater = new LauncherUpdater( + manifestClient, + artifactClient, + versions, + selfUpdates, + launcherVersion, + rid, + launcherTargetDirectory, + hasRunningSessions); + return new LauncherUpdateComposition( + versions, + LauncherExecutableSet.FromCurrentVersionStore(versions), + updater, + artifactClient, + manifestClient); + } + catch (Exception ex) when (IsStorageFailure(ex)) + { + manifestClient?.Dispose(); + artifactClient?.Dispose(); + string status = "Versioned client update storage is unavailable: " + + (string.IsNullOrWhiteSpace(ex.Message) + ? "the storage operation failed." + : ex.Message); + var resolution = new ClientVersionResolution( + ClientVersionState.Invalid, + status, + null, + null, + null, + null); + return new LauncherUpdateComposition( + versions, + LauncherExecutableSet.Unavailable(status), + new UnavailableLauncherUpdater(status, resolution), + artifactClient: null, + manifestClient: null); + } + } + + public void Dispose() + { + _manifestClient?.Dispose(); + _artifactClient?.Dispose(); + } + + private static bool IsStorageFailure(Exception exception) => exception is + IOException + or UnauthorizedAccessException + or SecurityException + or JsonException + or FormatException + or NotSupportedException + or LauncherUpdateException; +} diff --git a/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs b/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs index 97161d89..c196cdd8 100644 --- a/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs +++ b/src/AcDream.Launcher/ViewModels/LauncherUpdateViewModel.cs @@ -479,42 +479,52 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable internal sealed class UnavailableLauncherUpdater : ILauncherUpdater { - private static readonly ClientVersionResolution Missing = new( - ClientVersionState.Missing, - "Versioned client updater is unavailable.", - null, - null, - null, - null); + private readonly ClientVersionResolution _resolution; + private readonly string _status; - public ClientVersionResolution CurrentClient => Missing; + public UnavailableLauncherUpdater( + string status = "Versioned client updater is unavailable.", + ClientVersionResolution? resolution = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(status); + _status = status; + _resolution = resolution ?? new ClientVersionResolution( + ClientVersionState.Invalid, + status, + null, + null, + null, + null); + } + + public ClientVersionResolution CurrentClient => _resolution; public Task InitializeAsync( CancellationToken cancellationToken = default) => - Task.FromResult(Missing); + Task.FromResult(_resolution); public Task CheckAsync( CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); public Task InstallClientAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); public Task StageLauncherAsync( LauncherUpdateCheckResult check, IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); public Task RollbackClientAsync( IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromException( - new LauncherUpdateException("Versioned client updater is unavailable.")); + new LauncherUpdateException(_status)); } diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index c33d0a93..8e3f45ac 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -5,15 +5,176 @@ using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Updates; using AcDream.Platform; -return args.FirstOrDefault() switch +const string SelfUpdateDataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA"; +const string SelfUpdateTargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET"; +const string SelfUpdateHelperPidEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID"; + +string[] effectiveArgs = args; +string? selfUpdateData = Environment.GetEnvironmentVariable(SelfUpdateDataEnvironment); +string? selfUpdateTarget = Environment.GetEnvironmentVariable(SelfUpdateTargetEnvironment); +if (!string.IsNullOrWhiteSpace(selfUpdateData) + && !string.IsNullOrWhiteSpace(selfUpdateTarget) + && IsBootstrapInvocation(effectiveArgs)) { - "hold-install-lease" => await HoldInstallLeaseAsync(args[1..]), - "hold-update-lease" => await HoldUpdateLeaseAsync(args[1..]), - "orphan-parent" => await RunOrphanParentAsync(args[1..]), - "orphan-child" => RunOrphanChild(args[1..]), + if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument + && Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid + && !string.IsNullOrWhiteSpace(helperPid)) + { + File.WriteAllText( + Path.GetFullPath(helperPid), + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + } + + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http); + SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync( + effectiveArgs, + manager, + Path.GetFullPath(selfUpdateTarget), + Path.GetFullPath( + Environment.ProcessPath + ?? throw new InvalidOperationException("Process path is unavailable."))); + if (startup.ShouldExit) + { + return startup.ExitCode; + } + + effectiveArgs = startup.RemainingArguments; +} + +return effectiveArgs.FirstOrDefault() switch +{ + "hold-install-lease" => await HoldInstallLeaseAsync(effectiveArgs[1..]), + "hold-update-lease" => await HoldUpdateLeaseAsync(effectiveArgs[1..]), + "orphan-parent" => await RunOrphanParentAsync(effectiveArgs[1..]), + "orphan-child" => RunOrphanChild(effectiveArgs[1..]), + "crash-self-update" => await CrashSelfUpdateAsync(effectiveArgs[1..]), + "stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]), + "bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]), + "canonical-probe" => CanonicalProbe(effectiveArgs[1..]), _ => 2, }; +static bool IsBootstrapInvocation(string[] arguments) => + arguments.Length > 0 + && arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument + or LauncherSelfUpdateBootstrap.ConfirmArgument + or LauncherSelfUpdateBootstrap.DeferredArgument + or "canonical-probe"; + +static ApplicationPathSet Paths(string dataDirectory) +{ + string data = Path.GetFullPath(dataDirectory); + return new ApplicationPathSet( + Path.Combine(data, "fixture-config"), + data, + Path.Combine(data, "fixture-cache"), + null); +} + +static async Task CrashSelfUpdateAsync(string[] arguments) +{ + if (arguments.Length != 4) + { + return 2; + } + + string dataDirectory = Path.GetFullPath(arguments[0]); + string targetDirectory = Path.GetFullPath(arguments[1]); + string readyPath = Path.GetFullPath(arguments[2]); + string canonicalName = arguments[3]; + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager( + Paths(dataDirectory), + http, + null, + observation => + { + if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation + && string.Equals( + observation.Path, + canonicalName, + StringComparison.Ordinal)) + { + if (!File.Exists(Path.Combine(targetDirectory, canonicalName))) + { + throw new InvalidOperationException( + "The canonical launcher vanished at the apply boundary."); + } + + File.WriteAllText(readyPath, Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + Thread.Sleep(Timeout.Infinite); + } + }); + using UpdateSessionBarrier.ExclusiveLease lease = manager.Barrier.AcquireExclusive(); + _ = await manager.ApplyPendingAsync(targetDirectory); + return 0; +} + +static async Task StageSelfUpdateAsync(string[] arguments) +{ + if (arguments.Length != 7 + || !long.TryParse( + arguments[6], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out long size)) + { + return 2; + } + + string dataDirectory = Path.GetFullPath(arguments[0]); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(Paths(dataDirectory), http); + _ = await manager.StageAsync( + LauncherVersion.Parse(arguments[2]), + arguments[3], + new ReleaseArtifact(new Uri(arguments[4]), arguments[5], size), + Path.GetFullPath(arguments[1]), + progress: null, + CancellationToken.None); + return 0; +} + +static async Task BootstrapProbeAsync(string[] arguments) +{ + if (arguments.Length != 4) + { + return 2; + } + + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http); + SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + manager, + Path.GetFullPath(arguments[1]), + Path.GetFullPath(arguments[2])); + File.WriteAllText( + Path.GetFullPath(arguments[3]), + result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments)); + return result.ShouldExit ? 3 : 0; +} + +static int CanonicalProbe(string[] arguments) +{ + if (arguments.Length != 1) + { + return 2; + } + + File.WriteAllText( + Path.GetFullPath(arguments[0]), + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "|" + + Path.GetFullPath( + Environment.ProcessPath + ?? throw new InvalidOperationException("Process path is unavailable."))); + return 0; +} + static async Task HoldUpdateLeaseAsync(string[] arguments) { if (arguments.Length != 4 diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs index 283d680e..83ad5402 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherOrchestratorTests.cs @@ -55,6 +55,42 @@ public sealed class LauncherOrchestratorTests : IDisposable using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive(); } + [Fact] + public async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal() + { + var supervisors = new BlockingStopSupervisorFactory(); + var barrier = new UpdateSessionBarrier(_paths.DataDirectory); + LauncherOrchestrator orchestrator = CreateOrchestrator( + supervisorFactory: supervisors, + updateSessionBarrier: barrier); + try + { + _ = await orchestrator.LaunchAsync( + "Local ACE", + "testaccount", + "+Acdream", + LaunchMode.Headless); + BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created); + + Task disposal = Task.Run(orchestrator.Dispose); + Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5))); + + Assert.False(disposal.IsCompleted); + Assert.Throws(barrier.AcquireExclusive); + + supervisor.AllowTerminal.Set(); + await disposal.WaitAsync(TimeSpan.FromSeconds(5)); + using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive(); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + Assert.True(supervisor.Disposed); + } + finally + { + supervisors.AllowEveryStop(); + orchestrator.Dispose(); + } + } + [Fact] public void SnapshotProjectsTheFullHierarchyWithoutTheCredential() { @@ -756,6 +792,59 @@ public sealed class LauncherOrchestratorTests : IDisposable } } + private sealed class BlockingStopSupervisorFactory : ILauncherProcessSupervisorFactory + { + public List Created { get; } = []; + + public ILauncherProcessSupervisor Create() + { + var supervisor = new BlockingStopSupervisor(); + Created.Add(supervisor); + return supervisor; + } + + public void AllowEveryStop() + { + foreach (BlockingStopSupervisor supervisor in Created) + { + supervisor.AllowTerminal.Set(); + } + } + } + + private sealed class BlockingStopSupervisor : ILauncherProcessSupervisor + { + public ManualResetEventSlim StopEntered { get; } = new(false); + + public ManualResetEventSlim AllowTerminal { get; } = new(false); + + public LauncherSessionState State { get; private set; } = + LauncherSessionState.Starting; + + public int? ExitCode { get; private set; } + + public bool Disposed { get; private set; } + + public event EventHandler? StateChanged; + + public void Start(LauncherProcessSpec spec, string? password) + { + State = LauncherSessionState.Running; + StateChanged?.Invoke(this, State); + } + + public void Stop(TimeSpan timeout) + { + StopEntered.Set(); + AllowTerminal.Wait(); + State = LauncherSessionState.Exited; + ExitCode = 0; + StateChanged?.Invoke(this, State); + } + + public void Dispose() => Disposed = true; + } + private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory { public List Created { get; } = []; diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs index 33ab8d86..ede382c9 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ClientVersionStoreTests.cs @@ -177,6 +177,61 @@ public sealed class ClientVersionStoreTests : IDisposable Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix))); } + [Fact] + public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var store = new ClientVersionStore(_paths); + ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case"); + await File.WriteAllTextAsync( + Path.Combine(installed.Directory!, "INSTALL.JSON"), + "must-not-be-hidden"); + + ClientVersionResolution resolution = await new ClientVersionStore(_paths) + .LoadAndRecoverAsync(_rid); + + Assert.Equal(ClientVersionState.Invalid, resolution.State); + Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue() + { + var store = new ClientVersionStore(_paths); + Directory.CreateDirectory(store.AppDirectory); + string id = Guid.NewGuid().ToString("N"); + string nearId = id[..31] + "g"; + string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id); + string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id); + string exactDownload = Path.Combine( + store.AppDirectory, + ".client-download-" + id + ".zip"); + string nearStaging = exactStaging + "-user"; + string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId); + string nearDownload = Path.Combine( + store.AppDirectory, + ".client-download-" + id + ".zip.user"); + Directory.CreateDirectory(exactStaging); + Directory.CreateDirectory(exactCorrupt); + Directory.CreateDirectory(nearStaging); + Directory.CreateDirectory(nearCorrupt); + await File.WriteAllTextAsync(exactDownload, "owned"); + await File.WriteAllTextAsync(nearDownload, "preserve"); + + _ = await store.LoadAndRecoverAsync(_rid); + + Assert.False(Directory.Exists(exactStaging)); + Assert.False(Directory.Exists(exactCorrupt)); + Assert.False(File.Exists(exactDownload)); + Assert.True(Directory.Exists(nearStaging)); + Assert.True(Directory.Exists(nearCorrupt)); + Assert.True(File.Exists(nearDownload)); + } + private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index 326f6011..fe4f0621 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -1,23 +1,9 @@ -using System.Text.Json; -using System.Text.Json.Serialization; using AcDream.Launcher.Core.Updates; namespace AcDream.Launcher.Core.Tests.Updates; public sealed class LauncherSelfUpdateManagerTests : IDisposable { - private static readonly JsonSerializerOptions PlanOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - Converters = - { - new JsonStringEnumConverter( - JsonNamingPolicy.CamelCase, - allowIntegerValues: false), - }, - }; - private readonly string _root = Path.Combine( Path.GetTempPath(), "acdream-self-update-tests", @@ -47,6 +33,12 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable await File.WriteAllTextAsync(unrelated, "preserve"); Assert.Null(await harness.Manager.LoadPendingAsync()); + using UpdateSessionBarrier.ExclusiveLease lease = + harness.Manager.Barrier.AcquireExclusive(); + Assert.True(harness.Manager.CleanupOwnedResidueUnderLease( + pending: null, + harness.Target, + lease)); Assert.False(Directory.Exists(orphan)); Assert.False(File.Exists(temporary)); @@ -121,49 +113,91 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable } [Fact] - public async Task CrashDuringApplyingReplaysReverseJournalToStagedState() + public async Task PriorOwnershipRemovesObsoleteFilesAndRollbackRestoresThem() + { + using var harness = new Harness(_root); + byte[] firstArchive = UpdateTestData.CreateZip( + [ + (harness.LauncherName, "launcher-v2"u8.ToArray(), 0x81ED), + ("support.dat", "support-v2"u8.ToArray(), 0x81A4), + ("obsolete.dll", "obsolete-v2"u8.ToArray(), 0x81A4), + ]); + _ = await harness.StageAsync("2.0.0", firstArchive); + SelfUpdatePlan first = await harness.Manager.ApplyPendingAsync(harness.Target); + await harness.Manager.ConfirmAsync( + first.TransactionId, + harness.Target, + harness.LauncherPath); + await harness.Manager.CompleteConfirmedAsync(first.TransactionId, harness.Target); + string obsoletePath = Path.Combine(harness.Target, "obsolete.dll"); + Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath)); + + byte[] secondArchive = UpdateTestData.CreateZip( + [ + (harness.LauncherName, "launcher-v3"u8.ToArray(), 0x81ED), + ("support.dat", "support-v3"u8.ToArray(), 0x81A4), + ]); + _ = await harness.StageAsync("3.0.0", secondArchive); + SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); + + Assert.False(File.Exists(obsoletePath)); + Assert.Contains( + applied.Apply!, + entry => entry.Path == "obsolete.dll" + && entry.Operation == SelfUpdateApplyOperation.Remove + && entry.HadOriginal); + + SelfUpdatePlan rolledBack = await harness.Manager + .RollbackAwaitingConfirmationAsync(harness.Target); + + Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); + Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath)); + Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath)); + Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath)); + + SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target); + await harness.Manager.ConfirmAsync( + retried.TransactionId, + harness.Target, + harness.LauncherPath); + await harness.Manager.CompleteConfirmedAsync(retried.TransactionId, harness.Target); + + Assert.False(File.Exists(obsoletePath)); + string ownership = await File.ReadAllTextAsync(Path.Combine( + harness.Target, + LauncherSelfUpdateManager.InstallRecordFileName)); + Assert.DoesNotContain("obsolete.dll", ownership, StringComparison.Ordinal); + } + + [Fact] + public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState() { using var harness = new Harness(_root); _ = await harness.StageAsync(); - SelfUpdatePlan staged = Assert.IsType( - await harness.Manager.LoadPendingAsync()); - SelfUpdateApplyEntry[] apply = staged.Files - .Select(file => new SelfUpdateApplyEntry( - file.Path, - File.Exists(Path.Combine( - harness.Target, - file.Path.Replace('/', Path.DirectorySeparatorChar))))) - .ToArray(); - SelfUpdatePlan applying = staged with + LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation => { - State = SelfUpdatePlanState.Applying, - Apply = apply, - }; - await File.WriteAllTextAsync( - harness.Manager.PendingPlanPath, - JsonSerializer.Serialize(applying, PlanOptions)); + if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation + && string.Equals( + observation.Path, + harness.LauncherName, + StringComparison.Ordinal)) + { + Assert.True(File.Exists(harness.LauncherPath)); + throw new InvalidOperationException("failpoint"); + } + }); - SelfUpdateApplyEntry first = apply[0]; - string payload = Path.Combine( - harness.Manager.GetPayloadDirectory(staged.TransactionId), - first.Path.Replace('/', Path.DirectorySeparatorChar)); - string target = Path.Combine( - harness.Target, - first.Path.Replace('/', Path.DirectorySeparatorChar)); - string backup = Path.Combine( - harness.Manager.GetBackupDirectory(staged.TransactionId), - first.Path.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(backup)!); - File.Move(target, backup); - File.Move(payload, target); - - SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target); + InvalidOperationException failure = await Assert.ThrowsAsync( + () => faulting.ApplyPendingAsync(harness.Target)); + SelfUpdatePlan recovered = Assert.IsType( + await harness.Manager.LoadPendingAsync()); + Assert.Equal("failpoint", failure.Message); Assert.Equal(SelfUpdatePlanState.Staged, recovered.State); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine( - harness.Manager.GetPayloadDirectory(staged.TransactionId), + harness.Manager.GetPayloadDirectory(recovered.TransactionId), harness.LauncherName))); } @@ -219,8 +253,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable Assert.False(confirmation.ShouldExit); Assert.Empty(confirmation.RemainingArguments); - Assert.True(harness.Manager.IsConfirmed(applied.TransactionId)); - await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target); + Assert.False(File.Exists(harness.Manager.PendingPlanPath)); + Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); } private sealed class Harness : IDisposable @@ -229,9 +263,11 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable private readonly HttpClient _http = new(); private readonly byte[] _archive; private readonly ReleaseArtifact _artifact; + private readonly string _root; public Harness(string root) { + _root = root; Target = Path.Combine(root, "published launcher"); Directory.CreateDirectory(Target); Rid = LauncherRuntimeIdentity.DetectRid(); @@ -270,6 +306,25 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable progress: null, CancellationToken.None); + public Task StageAsync(string version, byte[] archive) + { + _server.Add("launcher.zip", archive); + return Manager.StageAsync( + LauncherVersion.Parse(version), + Rid, + new ReleaseArtifact( + _server.UriFor("launcher.zip"), + UpdateTestData.Sha256(archive), + archive.LongLength), + Target, + progress: null, + CancellationToken.None); + } + + public LauncherSelfUpdateManager CreateManagerWithObserver( + Action observer) => + new(UpdateTestData.Paths(_root), _http, null, observer); + public void Dispose() { _http.Dispose(); diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs new file mode 100644 index 00000000..14731094 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -0,0 +1,453 @@ +using System.Diagnostics; +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Updates; + +namespace AcDream.Launcher.Core.Tests.Updates; + +public sealed class LauncherSelfUpdateProcessTests : IDisposable +{ + private const string FixtureBaseName = + "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder"; + private const string DataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA"; + private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET"; + private const string HelperPidEnvironment = + "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "acdream-self-update-process-tests", + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string ready = Path.Combine(_root, "crash.ready"); + string launched = Path.Combine(_root, "replacement.ready"); + string helperPidPath = Path.Combine(_root, "helper.pid"); + Directory.CreateDirectory(_root); + string rid = LauncherRuntimeIdentity.DetectRid(); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + string oldHash = await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", prepared.NewArchive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + SelfUpdateStageResult staged = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(prepared.NewArchive), + prepared.NewArchive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + + using Process crash = StartFixture( + ["crash-self-update", data, target, ready, prepared.CanonicalName]); + try + { + await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20)); + Assert.True(File.Exists(prepared.CanonicalPath)); + crash.Kill(entireProcessTree: true); + await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.True(File.Exists(prepared.CanonicalPath)); + string boundaryHash = await FileIntegrity.ComputeSha256HexAsync( + prepared.CanonicalPath); + Assert.Contains(boundaryHash, new[] { oldHash, prepared.NewCanonicalHash }); + + var environment = new Dictionary + { + [DataEnvironment] = data, + [TargetEnvironment] = target, + [HelperPidEnvironment] = helperPidPath, + }; + using Process canonical = StartProcess( + prepared.CanonicalPath, + ["canonical-probe", launched], + environment); + await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); + Assert.Equal(0, canonical.ExitCode); + await WaitForFileAsync(launched, process: null, TimeSpan.FromSeconds(30)); + await WaitUntilAsync( + () => !File.Exists(manager.PendingPlanPath), + TimeSpan.FromSeconds(30), + "The self-update journal did not converge."); + + Assert.Equal( + prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath)); + Assert.True(File.Exists(Path.Combine( + target, + LauncherSelfUpdateManager.InstallRecordFileName))); + Assert.False(Directory.Exists(manager.GetTransactionDirectory( + plan.TransactionId))); + Assert.Empty(Directory.EnumerateDirectories( + target, + ".acdream-self-update-*", + SearchOption.TopDirectoryOnly)); + Assert.Null(await manager.LoadPendingAsync()); + + int replacementPid = ParsePid(await File.ReadAllTextAsync(launched)); + int helperPid = int.Parse( + await File.ReadAllTextAsync(helperPidPath), + System.Globalization.CultureInfo.InvariantCulture); + await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10)); + await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10)); + if (OperatingSystem.IsLinux()) + { + Assert.True( + (File.GetUnixFileMode(prepared.CanonicalPath) + & (UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute)) != 0); + } + } + finally + { + if (!crash.HasExited) + { + crash.Kill(entireProcessTree: true); + await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + } + } + + [Fact] + public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string resultPath = Path.Combine(_root, "startup.result"); + Directory.CreateDirectory(target); + string rid = LauncherRuntimeIdentity.DetectRid(); + string canonicalName = LauncherName(rid); + string canonical = Path.Combine(target, canonicalName); + await File.WriteAllTextAsync(canonical, "running launcher"); + byte[] archive = UpdateTestData.LauncherZip( + rid, + new string('s', 2 * 1024 * 1024)); + using var server = new LocalHttpFixture(); + server.Add( + "slow-launcher.zip", + archive, + chunkSize: 4096, + chunkDelay: TimeSpan.FromMilliseconds(2)); + var observer = new LauncherSelfUpdateManager( + UpdateTestData.Paths(_root), + new HttpClient()); + using Process staging = StartFixture( + [ + "stage-self-update", + data, + target, + "2.0.0", + rid, + server.UriFor("slow-launcher.zip").AbsoluteUri, + UpdateTestData.Sha256(archive), + archive.LongLength.ToString(System.Globalization.CultureInfo.InvariantCulture), + ]); + try + { + await WaitUntilAsync( + () => Directory.Exists(observer.TransactionsDirectory) + && Directory.EnumerateDirectories(observer.TransactionsDirectory).Any(), + TimeSpan.FromSeconds(10), + "The slow staging transaction did not become visible.", + staging); + Assert.False(File.Exists(observer.PendingPlanPath)); + string transaction = Assert.Single( + Directory.EnumerateDirectories(observer.TransactionsDirectory)); + + using Process startup = StartFixture( + ["bootstrap-probe", data, target, canonical, resultPath]); + await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(0, startup.ExitCode); + Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath)); + Assert.True(Directory.Exists(transaction)); + Assert.False(File.Exists(observer.PendingPlanPath)); + + await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + Assert.Equal(0, staging.ExitCode); + SelfUpdatePlan plan = Assert.IsType( + await observer.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Staged, plan.State); + Assert.True(Directory.Exists(transaction)); + } + finally + { + if (!staging.HasExited) + { + staging.Kill(entireProcessTree: true); + await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + } + } + + [Fact] + public async Task HelperDefersWithoutRestartWhenSharedSessionLeaseAppears() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string unexpectedLaunch = Path.Combine(_root, "unexpected-launch"); + string helperPid = Path.Combine(_root, "helper.pid"); + Directory.CreateDirectory(target); + string rid = LauncherRuntimeIdentity.DetectRid(); + string canonical = Path.Combine(target, LauncherName(rid)); + await File.WriteAllTextAsync(canonical, "old-launcher"); + byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher"); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", archive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + _ = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(archive), + archive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + using UpdateSessionBarrier.SessionLease session = manager.Barrier.AcquireSession(); + var environment = new Dictionary + { + [DataEnvironment] = data, + [TargetEnvironment] = target, + [HelperPidEnvironment] = helperPid, + }; + + using Process helper = StartFixture( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture), + target, + plan.TransactionId, + "canonical-probe", + unexpectedLaunch, + ], environment); + await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode); + Assert.True(File.Exists(helperPid)); + Assert.False(File.Exists(unexpectedLaunch)); + Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical)); + SelfUpdatePlan deferred = Assert.IsType( + await manager.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Staged, deferred.State); + Assert.Equal(plan.TransactionId, deferred.TransactionId); + } + + private PreparedLauncher PrepareLauncherClosure(string target, string rid) + { + string fixtureDirectory = GetFixtureDirectory(); + string fixtureAppHost = Path.Combine( + fixtureDirectory, + FixtureBaseName + (OperatingSystem.IsWindows() ? ".exe" : string.Empty)); + Assert.True(File.Exists(fixtureAppHost), $"Missing fixture apphost: {fixtureAppHost}"); + Directory.CreateDirectory(target); + string canonicalName = LauncherName(rid); + string canonicalPath = Path.Combine(target, canonicalName); + var archiveEntries = new List<(string Name, byte[] Content, int? UnixAttributes)>(); + foreach (string source in Directory.EnumerateFiles( + fixtureDirectory, + "*", + SearchOption.TopDirectoryOnly)) + { + string sourceName = Path.GetFileName(source); + bool isAppHost = PathsEqual(source, fixtureAppHost); + string targetName = isAppHost ? canonicalName : sourceName; + byte[] oldContent = File.ReadAllBytes(source); + byte[] newContent = oldContent; + if (isAppHost) + { + oldContent = [.. oldContent, .. "-old"u8.ToArray()]; + newContent = [.. newContent, .. "-new"u8.ToArray()]; + } + + string targetPath = Path.Combine(target, targetName); + File.WriteAllBytes(targetPath, oldContent); + int unixAttributes = 0x81A4; + if (OperatingSystem.IsLinux()) + { + UnixFileMode mode = File.GetUnixFileMode(source); + if (isAppHost) + { + mode |= UnixFileMode.UserExecute; + } + + File.SetUnixFileMode(targetPath, mode); + unixAttributes = 0x8000 | (int)mode; + } + else if (isAppHost) + { + unixAttributes = 0x81ED; + } + + archiveEntries.Add((targetName, newContent, unixAttributes)); + } + + byte[] archive = UpdateTestData.CreateZip(archiveEntries); + byte[] newCanonical = Assert.Single( + archiveEntries, + entry => entry.Name == canonicalName).Content; + return new PreparedLauncher( + canonicalName, + canonicalPath, + archive, + UpdateTestData.Sha256(newCanonical)); + } + + private static Process StartFixture( + IReadOnlyList arguments, + IReadOnlyDictionary? environment = null) => + StartProcess("dotnet", [GetFixtureDllPath(), .. arguments], environment); + + private static Process StartProcess( + string executable, + IReadOnlyList arguments, + IReadOnlyDictionary? environment = null) + { + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + if (environment is not null) + { + foreach ((string name, string value) in environment) + { + start.Environment[name] = value; + } + } + + return Process.Start(start) + ?? throw new InvalidOperationException($"Could not start '{executable}'."); + } + + private static async Task WaitForFileAsync( + string path, + Process? process, + TimeSpan timeout) => + await WaitUntilAsync( + () => File.Exists(path), + timeout, + $"Timed out waiting for '{path}'.", + process); + + private static async Task WaitUntilAsync( + Func condition, + TimeSpan timeout, + string failure, + Process? process = null) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; + while (!condition()) + { + if (process?.HasExited == true) + { + throw new InvalidOperationException( + $"{failure} Process exited {process.ExitCode}. stdout: " + + await process.StandardOutput.ReadToEndAsync() + + " stderr: " + + await process.StandardError.ReadToEndAsync()); + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException(failure); + } + + await Task.Delay(20); + } + } + + private static int ParsePid(string marker) + { + string value = marker.Split('|', 2)[0]; + return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static async Task WaitForProcessExitAsync(int pid, TimeSpan timeout) + { + try + { + using Process process = Process.GetProcessById(pid); + await process.WaitForExitAsync().WaitAsync(timeout); + } + catch (ArgumentException) + { + // It exited before the test opened the process handle. + } + } + + private static string LauncherName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + + private static string GetFixtureDllPath() => + Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll"); + + private static string GetFixtureDirectory() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder", + "bin", + configuration, + "net10.0"); + } + + private static string FindRepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException("Repository root was not found."); + } + + private static bool PathsEqual(string left, string right) => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private sealed record PreparedLauncher( + string CanonicalName, + string CanonicalPath, + byte[] NewArchive, + string NewCanonicalHash); +} diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs index eaa480ad..7430d701 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherUpdaterIntegrationTests.cs @@ -30,7 +30,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2"); ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); var updater = new LauncherUpdater( source, @@ -74,7 +75,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable byte[] launcher = UpdateTestData.LauncherZip(_rid); ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); var updater = new LauncherUpdater( source, @@ -108,7 +110,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable byte[] launcher = UpdateTestData.LauncherZip(_rid); ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); bool running = true; var updater = new LauncherUpdater( @@ -156,7 +159,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable launcher), contentType: "application/json"); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); var versions = new ClientVersionStore(_paths); var updater = new LauncherUpdater( source, diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index 84d788eb..b3d80e11 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using AcDream.Launcher.Core.Updates; @@ -63,7 +64,8 @@ public sealed class ReleaseManifestClientTests launcher), contentType: "application/json"); using var http = new HttpClient(); - using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); + using var source = ReleaseManifestClient.CreateLoopbackFixture( + server.UriFor("manifest.json")); ReleaseManifest manifest = await source.FetchAsync(); @@ -96,16 +98,132 @@ public sealed class ReleaseManifestClientTests ValidJson().Replace("\"size\":12", "\"size\":0"), ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"), ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""), - ValidJson().Replace("http://127.0.0.1", "http://example.test"), + ValidJson().Replace("https://example.test/client", "http://example.test/client"), }; + [Theory] + [InlineData("clients", "client")] + [InlineData("launchers", "launcher")] + public void ProductionManifestRejectsLoopbackHttpArtifacts( + string section, + string artifact) + { + string json = ValidJson().Replace( + $"https://example.test/{artifact}", + $"http://127.0.0.1/{artifact}", + StringComparison.Ordinal); + + LauncherUpdateException error = Assert.Throws(() => + ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json))); + + Assert.Contains(section, error.Message, StringComparison.Ordinal); + Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest() + { + var handler = new SequenceHandler((request, _) => Redirect( + HttpStatusCode.Found, + new Uri("http://127.0.0.1/manifest.json"))); + using var source = ReleaseManifestClient.CreateForTransportTest( + ReleaseManifestClient.ProductionManifestUri, + allowLoopbackHttp: false, + handler); + + LauncherUpdateException error = await Assert.ThrowsAsync( + () => source.FetchAsync()); + + Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal); + Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests); + } + + [Fact] + public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop() + { + var start = new Uri("https://example.test/start"); + var handler = new SequenceHandler((request, _) => Redirect( + HttpStatusCode.TemporaryRedirect, + new Uri("http://example.test/plaintext-hop"))); + using var source = ReleaseManifestClient.CreateForTransportTest( + start, + allowLoopbackHttp: false, + handler); + + await Assert.ThrowsAsync(() => source.FetchAsync()); + + Assert.Equal([start], handler.Requests); + } + + [Fact] + public async Task RedirectLoopIsRejectedWithoutRepeatingARequest() + { + var first = new Uri("https://example.test/first"); + var second = new Uri("https://example.test/second"); + var handler = new SequenceHandler((request, _) => Redirect( + HttpStatusCode.PermanentRedirect, + request.RequestUri == first ? second : first)); + using var source = ReleaseManifestClient.CreateForTransportTest( + first, + allowLoopbackHttp: false, + handler); + + LauncherUpdateException error = await Assert.ThrowsAsync( + () => source.FetchAsync()); + + Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal([first, second], handler.Requests); + } + + [Fact] + public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop() + { + var start = new Uri("https://example.test/hop-0"); + var handler = new SequenceHandler((_, index) => Redirect( + HttpStatusCode.Found, + new Uri($"https://example.test/hop-{index + 1}"))); + using var source = ReleaseManifestClient.CreateForTransportTest( + start, + allowLoopbackHttp: false, + handler); + + LauncherUpdateException error = await Assert.ThrowsAsync( + () => source.FetchAsync()); + + Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal); + Assert.Equal(6, handler.Requests.Count); + Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]); + } + private static string ValidJson() => - "{\"schemaVersion\":1,\"version\":\"2.0.0\"," - + "\"minimumLauncherVersion\":\"1.0.0\"," - + "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\"," - + $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}}," - + "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\"," - + $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}"; + $$$$""" + {"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}} + """; + + private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location) + { + var response = new HttpResponseMessage(status); + response.Headers.Location = location; + return response; + } + + private sealed class SequenceHandler( + Func respond) + : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Uri uri = request.RequestUri + ?? throw new InvalidOperationException("Test request has no URI."); + int index = Requests.Count; + Requests.Add(uri); + return Task.FromResult(respond(request, index)); + } + } } public sealed class VerifiedArtifactDownloaderTests : IDisposable @@ -211,9 +329,45 @@ public sealed class VerifiedArtifactDownloaderTests : IDisposable Assert.Equal("preserve", await File.ReadAllTextAsync(destination)); } + [Fact] + public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop() + { + var handler = new RedirectHandler(); + using var http = new HttpClient(handler); + var downloader = new VerifiedArtifactDownloader(http); + string destination = Path.Combine(_root, "redirect.zip"); + + LauncherUpdateException error = await Assert.ThrowsAsync(() => + downloader.DownloadAsync( + new ReleaseArtifact( + new Uri("https://example.test/artifact"), + new string('a', 64), + 12), + destination)); + + Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal); + Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests); + Assert.False(File.Exists(destination)); + } + private sealed class ImmediateProgress(Action callback) : IProgress { public void Report(ArtifactDownloadProgress value) => callback(value); } + + private sealed class RedirectHandler : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request.RequestUri!); + var response = new HttpResponseMessage(HttpStatusCode.Found); + response.Headers.Location = new Uri("http://example.test/plaintext"); + return Task.FromResult(response); + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs index e94f8580..c65efcdf 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/SafeZipExtractorTests.cs @@ -50,6 +50,12 @@ public sealed class SafeZipExtractorTests : IDisposable [InlineData("a/./b")] [InlineData("CON")] [InlineData("aux.txt")] + [InlineData("CLOCK$/value")] + [InlineData("CONIN$.txt")] + [InlineData("CONOUT$/value")] + [InlineData("COM¹.dll")] + [InlineData("com²/value")] + [InlineData("LPT³.log")] [InlineData("trailing.")] [InlineData("trailing ")] public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry) @@ -66,6 +72,15 @@ public sealed class SafeZipExtractorTests : IDisposable Assert.False(File.Exists(Path.Combine(_root, "escape"))); } + [Theory] + [InlineData("CONIN$.txt")] + [InlineData("CONOUT$/child")] + [InlineData("COM¹.dll")] + [InlineData("LPT³/child")] + [InlineData("CLOCK$")] + public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) => + Assert.False(ClientVersionStore.IsNormalizedRelative(path)); + [Fact] public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction() { diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs new file mode 100644 index 00000000..37365efe --- /dev/null +++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher.Tests; + +public sealed class LauncherUpdateCompositionTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-composition-tests", + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Theory] + [InlineData("io")] + [InlineData("permission")] + [InlineData("corrupt")] + public async Task StartupStorageFailureComposesUnavailableUpdaterWithoutThrowing( + string failure) + { + Directory.CreateDirectory(_root); + var paths = new ApplicationPathSet( + Path.Combine(_root, "config"), + Path.Combine(_root, "data"), + Path.Combine(_root, "cache"), + null); + Exception exception = failure switch + { + "io" => new IOException("storage offline"), + "permission" => new UnauthorizedAccessException("storage denied"), + "corrupt" => new JsonException("pointer corrupt"), + _ => throw new InvalidOperationException("Unknown fixture failure."), + }; + + using LauncherUpdateComposition composition = LauncherUpdateComposition.Create( + paths, + LauncherRuntimeIdentity.DetectRid(), + LauncherVersion.Parse("1.0.0"), + _root, + () => false, + (_, _) => throw exception); + + Assert.Equal(ClientVersionState.Invalid, composition.Updater.CurrentClient.State); + Assert.Contains( + exception.Message, + composition.Updater.CurrentClient.Status, + StringComparison.Ordinal); + LauncherCapability capability = composition.Executables.GetAvailability(LaunchMode.Gui); + Assert.False(capability.IsAvailable); + Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal); + LauncherUpdateException updateError = await Assert.ThrowsAsync( + () => composition.Updater.CheckAsync()); + Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); + } +} From 09d84387a8ee6f231b2a5b191d1928946a21062a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 23:41:55 +0200 Subject: [PATCH 050/138] fix(launcher): verify self-update rollback sources --- docs/plans/2026-08-14-launcher-campaign.md | 53 +- .../2026-08-14-launcher-campaign-design.md | 2 +- .../Updates/LauncherSelfUpdateBootstrap.cs | 37 +- .../Updates/LauncherSelfUpdateManager.cs | 685 ++++++++++++++++-- .../Updates/LauncherSelfUpdateManagerTests.cs | 55 +- .../Updates/LauncherSelfUpdateProcessTests.cs | 220 ++++++ 6 files changed, 949 insertions(+), 103 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 2ba8202d..180d320f 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -580,11 +580,11 @@ owns the lease and therefore releases after process death. Launcher self-update staging lives at `DataDirectory/launcher-update/transactions//` and the sole -durable authority is `DataDirectory/launcher-update/pending.json` (schema 2): +durable authority is `DataDirectory/launcher-update/pending.json` (schema 3): ```json { - "schemaVersion": 2, + "schemaVersion": 3, "transactionId": "0123456789abcdef0123456789abcdef", "state": "staged", "version": "1.2.3", @@ -608,18 +608,54 @@ owned metadata path, and obsolete paths from the previous ownership record: ```json [ - { "path": "acdream-launcher.exe", "operation": "install", "hadOriginal": true }, - { "path": "obsolete.dll", "operation": "remove", "hadOriginal": true } + { + "path": "acdream-launcher.exe", + "operation": "install", + "hadOriginal": true, + "priorSha256": "<64 hex characters>", + "priorSize": 123, + "priorUnixMode": 0, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + }, + { + "path": "new-support.dat", + "operation": "install", + "hadOriginal": false, + "priorSha256": null, + "priorSize": null, + "priorUnixMode": null, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + } ] ``` +Every `hadOriginal` entry persists the exact pre-mutation SHA-256, length, and +Linux mode bits; a no-original entry has all three prior fields null. Every +install entry likewise persists the verified replacement metadata, while a +remove entry has all three replacement fields null. The journal is invalid +unless those fields agree with `hadOriginal` and `operation`. + Existing targets are replaced with one same-filesystem atomic replace whose backup is also target-local. Previously absent noncanonical files use one same-filesystem rename; obsolete owned files use one rename into backup. The canonical launcher path therefore contains either the complete old file or the -complete new file at every durable crash boundary. Rollback reverses the same -operations atomically and is idempotent after a process/power loss. Linux mode -bits come from the verified incoming file. A helper that cannot immediately +complete new file at every durable crash boundary. Rollback first performs a +zero-mutation preflight of the complete target-local transaction and every +journal entry. It rejects reparse points, unsafe parents, unrecorded paths, +ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior, +incoming, or discard file. Only a fully preflighted rollback may atomically +restore backups; newly created files move to target-local discard rather than +being deleted. The complete prior target set is then reverified before the +plan enters durable `rolledBack` state while retaining the journal. Retry is +allowed only after that prior set is reverified again and the plan returns to +`staged`. Thus rollback is atomic per file and idempotent after a process/power +loss. Any ambiguity preserves the applying plan and transaction evidence and +forbids launching the canonical path for manual recovery. Linux mode bits come +from the verified incoming file. A helper that cannot immediately acquire the exclusive update lease defers the staged plan and exits without restarting the old launcher, preventing restart loops. @@ -645,6 +681,9 @@ instruction, after which the helper releases its lease and the confirmed launcher reclaims plan, data-transaction, and target-local residue. An `applying` plan is rolled back before retry, and failure to start/confirm the new launcher restores every original (and removes every no-original target). +The helper restarts the restored canonical launcher only after a fresh complete +verification of the retained `rolledBack` journal; rollback corruption or an +unsafe backup/discard tree exits without starting either launcher. Reading `pending.json` never performs cleanup. Ordinary startup attempts the exclusive lease without waiting and skips update cleanup entirely when another session/staging transaction owns it. All plan paths are re-derived/contained diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index be403756..db477433 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -305,7 +305,7 @@ preview would be a deliberate divergence we are NOT taking. The exact v1 manifest, extracted-version record, `current.json` activation pointer and launcher ownership record, shared-session/exclusive-update OS -lease, and durable self-update plan schema 2 are pinned in +lease, and durable self-update plan schema 3 are pinned in `docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater contracts (v1, BINDING)**. That section is normative: implementations reject unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs index e72bea60..c37b51e8 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -240,12 +240,10 @@ public static class LauncherSelfUpdateBootstrap targetDirectory, lease); Process? replacement = null; - bool appliedByThisHelper = false; try { plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) .ConfigureAwait(false); - appliedByThisHelper = true; replacement = Process.Start(startInfo) ?? throw new LauncherUpdateException( "The updated launcher could not be started."); @@ -283,26 +281,33 @@ public static class LauncherSelfUpdateBootstrap try { - if (appliedByThisHelper) + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + SelfUpdatePlan? rollbackReceipt = pending?.State switch { - SelfUpdatePlan? pending = await manager.LoadPendingAsync( - CancellationToken.None) - .ConfigureAwait(false); - if (pending?.State == SelfUpdatePlanState.Applying) - { - _ = await manager.RecoverApplyingAsync( + SelfUpdatePlanState.Applying => + await manager.RecoverApplyingAsync( targetDirectory, CancellationToken.None) - .ConfigureAwait(false); - } - else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation) - { - _ = await manager.RollbackAwaitingConfirmationAsync( + .ConfigureAwait(false), + SelfUpdatePlanState.AwaitingConfirmation => + await manager.RollbackAwaitingConfirmationAsync( targetDirectory, CancellationToken.None) - .ConfigureAwait(false); - } + .ConfigureAwait(false), + SelfUpdatePlanState.RolledBack => pending, + _ => null, + }; + if (rollbackReceipt?.State != SelfUpdatePlanState.RolledBack) + { + return 75; } + + await manager.VerifyRestoredPriorAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); } catch { diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs index 71ef0064..4d36a2bb 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -9,6 +9,7 @@ public enum SelfUpdatePlanState Staged, Applying, AwaitingConfirmation, + RolledBack, } public enum SelfUpdateApplyOperation @@ -20,7 +21,13 @@ public enum SelfUpdateApplyOperation public sealed record SelfUpdateApplyEntry( string Path, SelfUpdateApplyOperation Operation, - bool HadOriginal); + bool HadOriginal, + string? PriorSha256, + long? PriorSize, + int? PriorUnixMode, + string? ReplacementSha256, + long? ReplacementSize, + int? ReplacementUnixMode); public sealed record SelfUpdatePlan( int SchemaVersion, @@ -34,7 +41,7 @@ public sealed record SelfUpdatePlan( IReadOnlyList Files, IReadOnlyList? Apply) { - public const int CurrentSchemaVersion = 2; + public const int CurrentSchemaVersion = 3; } public sealed record LauncherBinaryInstallRecord( @@ -94,6 +101,14 @@ public sealed class LauncherSelfUpdateManager private readonly SafeZipExtractor _extractor; private readonly Action? _applyObserver; + private sealed record JournalFileMetadata(string Sha256, long Size, int UnixMode); + + private sealed record RollbackAction( + SelfUpdateApplyEntry Entry, + string TargetPath, + string BackupPath, + string DiscardPath); + public LauncherSelfUpdateManager( ApplicationPathSet paths, HttpClient httpClient, @@ -310,17 +325,31 @@ public sealed class LauncherSelfUpdateManager .ConfigureAwait(false); } + if (plan.State == SelfUpdatePlanState.RolledBack) + { + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.Staged, + Apply = null, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + } + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync( expectedTarget, plan.Rid, cancellationToken) .ConfigureAwait(false); - IReadOnlyList apply = BuildApplyJournal( - plan, - previous, - expectedTarget); - await PrepareTargetTransactionAsync(plan, apply, cancellationToken) + IReadOnlyList apply = await BuildApplyJournalAsync( + plan, + previous, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); + apply = await PrepareTargetTransactionAsync(plan, apply, cancellationToken) .ConfigureAwait(false); plan = plan with { @@ -334,7 +363,8 @@ public sealed class LauncherSelfUpdateManager foreach (SelfUpdateApplyEntry entry in plan.Apply) { cancellationToken.ThrowIfCancellationRequested(); - ApplyEntry(plan, entry); + await ApplyEntryAsync(plan, entry, cancellationToken) + .ConfigureAwait(false); _applyObserver?.Invoke(new SelfUpdateApplyObservation( SelfUpdateApplyBoundary.AfterTargetMutation, entry.Path, @@ -367,6 +397,25 @@ public sealed class LauncherSelfUpdateManager : plan; } + internal async Task VerifyRestoredPriorAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no rolled-back self-update."); + ValidatePlan(plan, expectedTarget); + if (plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The pending self-update has no verified rollback receipt."); + } + + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + } + public async Task ConfirmAsync( string transactionId, string expectedTargetDirectory, @@ -497,10 +546,11 @@ public sealed class LauncherSelfUpdateManager "acdream-launcher" + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); - private IReadOnlyList BuildApplyJournal( + private async Task> BuildApplyJournalAsync( SelfUpdatePlan plan, LauncherBinaryInstallRecord? previous, - string targetDirectory) + string targetDirectory, + CancellationToken cancellationToken) { var operations = new Dictionary( StringComparer.OrdinalIgnoreCase); @@ -527,13 +577,12 @@ public sealed class LauncherSelfUpdateManager { string targetPath = ClientVersionStore.ResolveContained(targetDirectory, path); EnsureSafeParent(targetDirectory, targetPath); - if (Directory.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Self-update target '{path}' is unexpectedly a directory."); - } - - bool hadOriginal = File.Exists(targetPath); + JournalFileMetadata? prior = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Self-update target '{path}'", + cancellationToken) + .ConfigureAwait(false); + bool hadOriginal = prior is not null; if (operation == SelfUpdateApplyOperation.Remove && !hadOriginal) { throw new LauncherUpdateException( @@ -550,13 +599,22 @@ public sealed class LauncherSelfUpdateManager "The canonical launcher executable is missing before self-update."); } - result.Add(new SelfUpdateApplyEntry(path, operation, hadOriginal)); + result.Add(new SelfUpdateApplyEntry( + path, + operation, + hadOriginal, + prior?.Sha256, + prior?.Size, + prior?.UnixMode, + ReplacementSha256: null, + ReplacementSize: null, + ReplacementUnixMode: null)); } return result; } - private async Task PrepareTargetTransactionAsync( + private async Task> PrepareTargetTransactionAsync( SelfUpdatePlan plan, IReadOnlyList apply, CancellationToken cancellationToken) @@ -628,9 +686,36 @@ public sealed class LauncherSelfUpdateManager throw new LauncherUpdateException( "The target-local self-update incoming tree is incomplete."); } + + var completed = new List(apply.Count); + foreach (SelfUpdateApplyEntry entry in apply) + { + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + completed.Add(entry); + continue; + } + + JournalFileMetadata replacement = await CaptureRequiredFileMetadataAsync( + ClientVersionStore.ResolveContained(incoming, entry.Path), + $"Target-local incoming launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + completed.Add(entry with + { + ReplacementSha256 = replacement.Sha256, + ReplacementSize = replacement.Size, + ReplacementUnixMode = replacement.UnixMode, + }); + } + + return completed; } - private void ApplyEntry(SelfUpdatePlan plan, SelfUpdateApplyEntry entry) + private async Task ApplyEntryAsync( + SelfUpdatePlan plan, + SelfUpdateApplyEntry entry, + CancellationToken cancellationToken) { string swap = GetTargetTransactionDirectory(plan); string incoming = Path.Combine(swap, "incoming"); @@ -639,27 +724,33 @@ public sealed class LauncherSelfUpdateManager plan.TargetDirectory, entry.Path); string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); - EnsureSafeParent(plan.TargetDirectory, targetPath); - Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsurePathMissing(backupPath, $"Self-update backup '{entry.Path}'"); + + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Self-update target '{entry.Path}'"); + } if (entry.Operation == SelfUpdateApplyOperation.Remove) { - if (!entry.HadOriginal || !File.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Owned obsolete launcher file '{entry.Path}' vanished during apply."); - } - + EnsureSafeParent(swap, backupPath); File.Move(targetPath, backupPath); return; } - string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); - if (!File.Exists(incomingPath)) - { - throw new LauncherUpdateException( - $"Incoming launcher file '{entry.Path}' is missing."); - } + await VerifyReplacementFileAsync(entry, incomingPath, cancellationToken) + .ConfigureAwait(false); + EnsureSafeParent(swap, backupPath); if (entry.HadOriginal) { @@ -681,67 +772,470 @@ public sealed class LauncherSelfUpdateManager } string swap = GetTargetTransactionDirectory(plan); + IReadOnlyList actions = await PreflightRollbackAsync( + plan, + cancellationToken) + .ConfigureAwait(false); + foreach (RollbackAction action in actions) + { + cancellationToken.ThrowIfCancellationRequested(); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, action.TargetPath); + EnsureExistingParentsSafe(swap, action.BackupPath); + EnsureExistingParentsSafe(swap, action.DiscardPath); + if (action.Entry.Operation == SelfUpdateApplyOperation.Remove) + { + EnsureSafeParent(plan.TargetDirectory, action.TargetPath); + File.Move(action.BackupPath, action.TargetPath); + continue; + } + + EnsureSafeParent(swap, action.DiscardPath); + if (action.Entry.HadOriginal) + { + File.Replace( + action.BackupPath, + action.TargetPath, + action.DiscardPath, + ignoreMetadataErrors: true); + } + else + { + File.Move(action.TargetPath, action.DiscardPath); + } + } + + await VerifyRestoredPriorAsync(plan, plan.TargetDirectory, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.RolledBack, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + SafeZipExtractor.TryDeleteDirectory(swap); + return plan; + } + + private async Task> PreflightRollbackAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + if (!Directory.Exists(swap)) + { + throw new LauncherUpdateException( + "The target-local self-update rollback transaction is missing."); + } + + ClientVersionStore.RejectReparseTree(swap); + ValidateRollbackTree(plan, swap); + string incoming = Path.Combine(swap, "incoming"); string backup = Path.Combine(swap, "backup"); string discard = Path.Combine(swap, "rollback-discard"); - foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse()) + var actions = new List(); + foreach (SelfUpdateApplyEntry entry in plan.Apply!.Reverse()) { cancellationToken.ThrowIfCancellationRequested(); string targetPath = ClientVersionStore.ResolveContained( plan.TargetDirectory, entry.Path); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + string discardPath = ClientVersionStore.ResolveContained(discard, entry.Path); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsureExistingParentsSafe(swap, discardPath); + + JournalFileMetadata? target = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Rollback target '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? incomingFile = await CaptureOptionalFileMetadataAsync( + incomingPath, + $"Rollback incoming file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? backupFile = await CaptureOptionalFileMetadataAsync( + backupPath, + $"Rollback backup file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? discardedFile = await CaptureOptionalFileMetadataAsync( + discardPath, + $"Rollback discard file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + if (entry.Operation == SelfUpdateApplyOperation.Remove) { - if (File.Exists(backupPath)) + RequireMissing(incomingFile, entry.Path, "incoming"); + RequireMissing(discardedFile, entry.Path, "discard"); + if (backupFile is not null && target is null) { - if (File.Exists(targetPath) || Directory.Exists(targetPath)) - { - throw new LauncherUpdateException( - $"Obsolete launcher rollback target '{entry.Path}' was recreated."); - } - - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - File.Move(backupPath, targetPath); + RequirePriorMetadata(entry, backupFile, "rollback backup"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; } + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + if (entry.HadOriginal) + { + if (backupFile is not null + && target is not null + && incomingFile is null + && discardedFile is null) + { + RequirePriorMetadata(entry, backupFile, "rollback backup"); + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + if (incomingFile is not null && discardedFile is null) + { + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; + } + + if (incomingFile is null && discardedFile is not null) + { + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + } + + throw AmbiguousRollback(entry.Path); + } + + RequireMissing(backupFile, entry.Path, "backup"); + if (target is not null + && incomingFile is null + && discardedFile is null) + { + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); continue; } - if (entry.HadOriginal && File.Exists(backupPath)) + if (target is null && incomingFile is not null && discardedFile is null) { - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - if (File.Exists(targetPath)) - { - string discardPath = ClientVersionStore.ResolveContained( - discard, - entry.Path); - Directory.CreateDirectory(Path.GetDirectoryName(discardPath)!); - File.Replace( - backupPath, - targetPath, - discardPath, - ignoreMetadataErrors: true); - } - else - { - File.Move(backupPath, targetPath); - } + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; } - else if (!entry.HadOriginal && File.Exists(targetPath)) + + if (target is null && incomingFile is null && discardedFile is not null) { - File.Delete(targetPath); + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + return actions; + } + + private static void ValidateRollbackTree(SelfUpdatePlan plan, string swap) + { + RequireTransactionContainer(Path.Combine(swap, "incoming"), required: true); + RequireTransactionContainer(Path.Combine(swap, "backup"), required: false); + RequireTransactionContainer( + Path.Combine(swap, "rollback-discard"), + required: false); + var allowed = new HashSet(StringComparer.Ordinal) + { + "incoming", + }; + foreach (SelfUpdateApplyEntry entry in plan.Apply!) + { + if (entry.Operation == SelfUpdateApplyOperation.Install) + { + AddAllowedTreePath(allowed, "incoming", entry.Path); + AddAllowedTreePath(allowed, "rollback-discard", entry.Path); + } + + if (entry.HadOriginal) + { + AddAllowedTreePath(allowed, "backup", entry.Path); } } - SafeZipExtractor.TryDeleteDirectory(swap); - plan = plan with + foreach (string path in Directory.EnumerateFileSystemEntries( + swap, + "*", + SearchOption.AllDirectories)) { - State = SelfUpdatePlanState.Staged, - Apply = null, - }; - await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); - await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); - return plan; + string relative = Path.GetRelativePath(swap, path).Replace('\\', '/'); + if (!allowed.Contains(relative)) + { + throw new LauncherUpdateException( + $"The rollback transaction contains unrecorded path '{relative}'."); + } + } + } + + private static void RequireTransactionContainer(string path, bool required) + { + try + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Rollback container '{Path.GetFileName(path)}' is not a safe directory."); + } + } + catch (FileNotFoundException) when (!required) + { + } + catch (DirectoryNotFoundException) when (!required) + { + } + catch (FileNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + catch (DirectoryNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + } + + private static void AddAllowedTreePath( + HashSet allowed, + string container, + string relativePath) + { + allowed.Add(container); + string current = container; + foreach (string segment in relativePath.Split('/')) + { + current += "/" + segment; + allowed.Add(current); + } + } + + private static LauncherUpdateException AmbiguousRollback(string path) => new( + $"Rollback state for '{path}' is corrupt or ambiguous; transaction evidence was preserved."); + + private static void RequireMissing( + JournalFileMetadata? metadata, + string path, + string location) + { + if (metadata is not null) + { + throw new LauncherUpdateException( + $"Rollback {location} for '{path}' is unexpected; transaction evidence was preserved."); + } + } + + private static async Task VerifyRestoredPriorAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + if (plan.Apply is null) + { + throw new LauncherUpdateException("The rollback receipt is missing its apply journal."); + } + + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + string targetPath = ClientVersionStore.ResolveContained(targetDirectory, entry.Path); + EnsureExistingParentsSafe(targetDirectory, targetPath); + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Restored rollback target '{entry.Path}'"); + } + } + } + + private static async Task VerifyPriorFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Prior launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequirePriorMetadata(entry, actual, "prior launcher file"); + } + + private static async Task VerifyReplacementFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Replacement launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequireReplacementMetadata(entry, actual, "replacement launcher file"); + } + + private static void RequirePriorMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.PriorSha256, + entry.PriorSize, + entry.PriorUnixMode); + + private static void RequireReplacementMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.ReplacementSha256, + entry.ReplacementSize, + entry.ReplacementUnixMode); + + private static void RequireMetadata( + string path, + string description, + JournalFileMetadata actual, + string? expectedSha256, + long? expectedSize, + int? expectedUnixMode) + { + if (!string.Equals(actual.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase) + || actual.Size != expectedSize + || actual.UnixMode != expectedUnixMode) + { + throw new LauncherUpdateException( + $"The {description} '{path}' failed its rollback integrity check; " + + "transaction evidence was preserved."); + } + } + + private static async Task CaptureRequiredFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) => + await CaptureOptionalFileMetadataAsync(path, description, cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException($"{description} is missing."); + + private static async Task CaptureOptionalFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new LauncherUpdateException($"{description} is a directory or reparse point."); + } + + var before = new FileInfo(path); + long size = before.Length; + int unixMode = OperatingSystem.IsLinux() + ? (int)File.GetUnixFileMode(path) & 0x1FF + : 0; + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + var after = new FileInfo(path); + after.Refresh(); + if (!after.Exists + || (after.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 + || after.Length != size + || (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != unixMode)) + { + throw new LauncherUpdateException($"{description} changed while it was measured."); + } + + return new JournalFileMetadata(sha256, size, unixMode); + } + + private static void EnsurePathMissing(string path, string description) + { + try + { + _ = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + + throw new LauncherUpdateException($"{description} already exists."); } private async Task VerifyPayloadAsync( @@ -968,6 +1462,22 @@ public sealed class LauncherSelfUpdateManager || !Enum.IsDefined(entry.Operation) || (entry.Operation == SelfUpdateApplyOperation.Remove && !entry.HadOriginal) + || entry.HadOriginal != ( + ReleaseManifestClient.IsSha256(entry.PriorSha256) + && entry.PriorSize is >= 0 + && entry.PriorUnixMode is >= 0 and <= 0x1FF) + || entry.HadOriginal == ( + entry.PriorSha256 is null + && entry.PriorSize is null + && entry.PriorUnixMode is null) + || (entry.Operation == SelfUpdateApplyOperation.Install) != ( + ReleaseManifestClient.IsSha256(entry.ReplacementSha256) + && entry.ReplacementSize is >= 0 + && entry.ReplacementUnixMode is >= 0 and <= 0x1FF) + || (entry.Operation == SelfUpdateApplyOperation.Install) == ( + entry.ReplacementSha256 is null + && entry.ReplacementSize is null + && entry.ReplacementUnixMode is null) || (prior is not null && string.Compare(prior, entry.Path, StringComparison.Ordinal) >= 0)) { @@ -1118,15 +1628,42 @@ public sealed class LauncherSelfUpdateManager throw new LauncherUpdateException("A self-update target has no parent."); } + EnsureExistingParentsSafe(root, filePath); Directory.CreateDirectory(parent); + EnsureExistingParentsSafe(root, filePath); + } + + private static void EnsureExistingParentsSafe(string root, string filePath) + { + string? parent = Path.GetDirectoryName(filePath); + if (parent is null) + { + throw new LauncherUpdateException("A self-update target has no parent."); + } + for (var directory = new DirectoryInfo(parent); directory is not null && IsContained(root, directory.FullName); directory = directory.Parent) { - if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + FileAttributes attributes; + try + { + attributes = File.GetAttributes(directory.FullName); + } + catch (FileNotFoundException) + { + continue; + } + catch (DirectoryNotFoundException) + { + continue; + } + + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) { throw new LauncherUpdateException( - $"Self-update target parent '{directory.FullName}' is a reparse point."); + $"Self-update target parent '{directory.FullName}' is not a safe directory."); } if (PathsEqual(directory.FullName, root)) diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index fe4f0621..e2c9b4b3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -1,4 +1,5 @@ using AcDream.Launcher.Core.Updates; +using System.Text.Json.Nodes; namespace AcDream.Launcher.Core.Tests.Updates; @@ -103,8 +104,16 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable SelfUpdatePlan rolledBack = await harness.Manager .RollbackAwaitingConfirmationAsync(harness.Target); - Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); - Assert.Null(rolledBack.Apply); + Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State); + Assert.All(rolledBack.Apply!, entry => + { + if (entry.HadOriginal) + { + Assert.Matches("^[0-9a-f]{64}$", entry.PriorSha256!); + Assert.NotNull(entry.PriorSize); + Assert.NotNull(entry.PriorUnixMode); + } + }); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target); @@ -150,7 +159,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable SelfUpdatePlan rolledBack = await harness.Manager .RollbackAwaitingConfirmationAsync(harness.Target); - Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State); + Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State); Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath)); @@ -170,7 +179,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable } [Fact] - public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState() + public async Task ApplyFailpointAfterCanonicalAtomicReplaceLeavesVerifiedRollbackReceipt() { using var harness = new Harness(_root); _ = await harness.StageAsync(); @@ -193,7 +202,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable await harness.Manager.LoadPendingAsync()); Assert.Equal("failpoint", failure.Message); - Assert.Equal(SelfUpdatePlanState.Staged, recovered.State); + Assert.Equal(SelfUpdatePlanState.RolledBack, recovered.State); + await harness.Manager.VerifyRestoredPriorAsync(harness.Target); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine( @@ -201,6 +211,41 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable harness.LauncherName))); } + [Fact] + public async Task ConditionalPriorIntegrityFieldsAreStrictAndFailClosed() + { + using var harness = new Harness(_root); + _ = await harness.StageAsync(); + SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); + SelfUpdateApplyEntry canonical = Assert.Single( + applied.Apply!, + entry => entry.Path == harness.LauncherName); + Assert.True(canonical.HadOriginal); + Assert.Matches("^[0-9a-f]{64}$", canonical.PriorSha256!); + Assert.NotNull(canonical.PriorSize); + Assert.NotNull(canonical.PriorUnixMode); + Assert.Matches("^[0-9a-f]{64}$", canonical.ReplacementSha256!); + + JsonObject document = Assert.IsType(JsonNode.Parse( + await File.ReadAllTextAsync(harness.Manager.PendingPlanPath))); + JsonArray apply = Assert.IsType(document["apply"]); + JsonObject canonicalNode = Assert.IsType(apply.Single(node => + string.Equals( + node?["path"]?.GetValue(), + harness.LauncherName, + StringComparison.Ordinal))); + canonicalNode["priorSha256"] = null; + await File.WriteAllTextAsync( + harness.Manager.PendingPlanPath, + document.ToJsonString()); + + await Assert.ThrowsAsync(() => + harness.Manager.LoadPendingAsync()); + Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); + Assert.True(Directory.Exists( + harness.Manager.GetTargetTransactionDirectory(applied))); + } + [Fact] public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed() { diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index 14731094..1c67efc3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -125,6 +125,116 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable } } + [Fact] + public async Task CorruptBackupAfterCanonicalCrashNeverLaunchesAndPreservesEvidence() + { + CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync(); + string backupPath = Path.Combine( + crashed.Manager.GetTargetTransactionDirectory(crashed.Plan), + "backup", + crashed.Prepared.CanonicalName); + Assert.True(File.Exists(backupPath)); + await File.WriteAllTextAsync(backupPath, "tampered rollback backup"); + string tamperedHash = await FileIntegrity.ComputeSha256HexAsync(backupPath); + string launched = Path.Combine(_root, "corrupt-backup-launched"); + string helperPidPath = Path.Combine(_root, "corrupt-backup-helper.pid"); + + using Process canonical = StartProcess( + crashed.Prepared.CanonicalPath, + ["canonical-probe", launched], + BootstrapEnvironment(crashed, helperPidPath)); + await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); + Assert.Equal(0, canonical.ExitCode); + await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); + await WaitForProcessExitAsync( + int.Parse( + await File.ReadAllTextAsync(helperPidPath), + System.Globalization.CultureInfo.InvariantCulture), + TimeSpan.FromSeconds(20)); + + Assert.False(File.Exists(launched)); + SelfUpdatePlan preserved = Assert.IsType( + await crashed.Manager.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Applying, preserved.State); + Assert.Equal(crashed.Plan.TransactionId, preserved.TransactionId); + Assert.True(Directory.Exists( + crashed.Manager.GetTargetTransactionDirectory(crashed.Plan))); + Assert.Equal(tamperedHash, await FileIntegrity.ComputeSha256HexAsync(backupPath)); + Assert.Equal( + crashed.Prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync(crashed.Prepared.CanonicalPath)); + await Assert.ThrowsAsync(() => + crashed.Manager.VerifyRestoredPriorAsync(crashed.Target)); + } + + [Fact] + public async Task BackupJunctionOrSymlinkAfterCanonicalCrashCannotMutateOutsideOrLaunch() + { + CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync(); + string swap = crashed.Manager.GetTargetTransactionDirectory(crashed.Plan); + string backup = Path.Combine(swap, "backup"); + string preservedBackup = Path.Combine(_root, "preserved-backup"); + string outside = Path.Combine(_root, "outside-backup"); + Directory.Move(backup, preservedBackup); + CopyDirectory(preservedBackup, outside); + string outsideCanonical = Path.Combine( + outside, + crashed.Prepared.CanonicalName); + string outsideHash = await FileIntegrity.ComputeSha256HexAsync(outsideCanonical); + CreateDirectoryLink(backup, outside); + string launched = Path.Combine(_root, "reparse-backup-launched"); + string helperPidPath = Path.Combine(_root, "reparse-backup-helper.pid"); + + try + { + using Process canonical = StartProcess( + crashed.Prepared.CanonicalPath, + ["canonical-probe", launched], + BootstrapEnvironment(crashed, helperPidPath)); + await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); + Assert.Equal(0, canonical.ExitCode); + await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); + await WaitForProcessExitAsync( + int.Parse( + await File.ReadAllTextAsync(helperPidPath), + System.Globalization.CultureInfo.InvariantCulture), + TimeSpan.FromSeconds(20)); + + Assert.False(File.Exists(launched)); + Assert.True(File.Exists(outsideCanonical)); + Assert.Equal( + outsideHash, + await FileIntegrity.ComputeSha256HexAsync(outsideCanonical)); + Assert.True( + (File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0); + SelfUpdatePlan preserved = Assert.IsType( + await crashed.Manager.LoadPendingAsync()); + Assert.Equal(SelfUpdatePlanState.Applying, preserved.State); + Assert.Equal( + crashed.Prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync( + crashed.Prepared.CanonicalPath)); + } + finally + { + try + { + if ((File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0) + { + Directory.Delete(backup); + } + } + catch (FileNotFoundException) + { + // The assertion above reports an unexpected missing link. + } + catch (DirectoryNotFoundException) + { + // The assertion above reports an unexpected missing link. + } + } + } + [Fact] public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction() { @@ -313,6 +423,109 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable UpdateTestData.Sha256(newCanonical)); } + private async Task PrepareKilledAfterCanonicalReplaceAsync() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string ready = Path.Combine(_root, "crash.ready"); + Directory.CreateDirectory(_root); + string rid = LauncherRuntimeIdentity.DetectRid(); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", prepared.NewArchive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + _ = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(prepared.NewArchive), + prepared.NewArchive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + using Process crash = StartFixture( + ["crash-self-update", data, target, ready, prepared.CanonicalName]); + await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20)); + crash.Kill(entireProcessTree: true); + await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(SelfUpdatePlanState.Applying, + Assert.IsType(await manager.LoadPendingAsync()).State); + return new CrashedUpdate(data, target, manager, plan, prepared); + } + + private static Dictionary BootstrapEnvironment( + CrashedUpdate crashed, + string helperPidPath) => new() + { + [DataEnvironment] = crashed.Data, + [TargetEnvironment] = crashed.Target, + [HelperPidEnvironment] = helperPidPath, + }; + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (string directory in Directory.EnumerateDirectories( + source, + "*", + SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine( + destination, + Path.GetRelativePath(source, directory))); + } + + foreach (string file in Directory.EnumerateFiles( + source, + "*", + SearchOption.AllDirectories)) + { + string target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode(target, File.GetUnixFileMode(file)); + } + } + } + + private static void CreateDirectoryLink(string link, string target) + { + if (!OperatingSystem.IsWindows()) + { + Directory.CreateSymbolicLink(link, target); + return; + } + + var start = new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + start.ArgumentList.Add("/d"); + start.ArgumentList.Add("/c"); + start.ArgumentList.Add("mklink"); + start.ArgumentList.Add("/J"); + start.ArgumentList.Add(link); + start.ArgumentList.Add(target); + using Process process = Process.Start(start) + ?? throw new InvalidOperationException("Could not create the test junction."); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + "Could not create the test junction: " + + process.StandardError.ReadToEnd() + + process.StandardOutput.ReadToEnd()); + } + } + private static Process StartFixture( IReadOnlyList arguments, IReadOnlyDictionary? environment = null) => @@ -450,4 +663,11 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable string CanonicalPath, byte[] NewArchive, string NewCanonicalHash); + + private sealed record CrashedUpdate( + string Data, + string Target, + LauncherSelfUpdateManager Manager, + SelfUpdatePlan Plan, + PreparedLauncher Prepared); } From f881e5b46724a571e6d25da5085bc7f4ca251256 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 23:09:08 +0200 Subject: [PATCH 051/138] feat(launcher): prepare Campaign LA11 user gate --- AcDream.slnx | 2 + docs/ISSUES.md | 66 +- docs/architecture/acdream-architecture.md | 5 +- docs/plans/2026-08-14-launcher-campaign.md | 7 +- .../2026-08-14-campaign-la-test-script.md | 571 ++++++++++++ .../2026-08-14-launcher-campaign-design.md | 4 +- .../Launching/ILauncherChildProcess.cs | 23 +- .../Launching/LauncherProcessSpec.cs | 8 +- .../Launching/LauncherProcessSupervisor.cs | 4 +- .../Launching/WindowsSystemChildProcess.cs | 843 ++++++++++++++++++ .../Orchestration/LauncherExecutableSet.cs | 3 +- src/AcDream.Launcher/AcDream.Launcher.csproj | 4 + .../LauncherStartupOptions.cs | 248 ++++++ ...e.Tests.Fixtures.ConsoleSignalChild.csproj | 10 + .../Program.cs | 54 ++ ...ixtures.ConsolelessSupervisorParent.csproj | 14 + .../Program.cs | 135 +++ .../AcDream.Launcher.Core.Tests.csproj | 12 + .../LauncherProcessSupervisorTests.cs | 318 +++++++ .../LauncherExecutableSetTests.cs | 10 + .../LauncherStartupOptionsTests.cs | 182 ++++ tools/new-campaign-la-update-fixture.ps1 | 333 +++++++ tools/run-campaign-la-preflight.ps1 | 394 ++++++++ tools/test-campaign-la-session-status.ps1 | 346 +++++++ 24 files changed, 3538 insertions(+), 58 deletions(-) create mode 100644 docs/research/2026-08-14-campaign-la-test-script.md create mode 100644 src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs create mode 100644 src/AcDream.Launcher/LauncherStartupOptions.cs create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs create mode 100644 tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs create mode 100644 tools/new-campaign-la-update-fixture.ps1 create mode 100644 tools/run-campaign-la-preflight.ps1 create mode 100644 tools/test-campaign-la-session-status.ps1 diff --git a/AcDream.slnx b/AcDream.slnx index 6080bb7f..b003cfca 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -28,6 +28,8 @@ + + diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 881ed834..421731ab 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,53 +26,41 @@ What does NOT go here: ## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host -**Status:** OPEN +**Status:** IN-PROGRESS — the isolated process-group implementation and real +Windows fixtures are complete; the LA11 connected acceptance row remains +required before closure. **Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE account session stuck for several minutes — a documented project landmine; see CLAUDE.md "Logout-before-reconnect") **Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3) **Component:** Launcher.Core / process supervision -**Description.** `LauncherProcessSupervisor.Stop` now attempts a graceful -stop signal (`ILauncherChildProcess.TryRequestGracefulStop`) BEFORE -`CloseMainWindow`. On Linux this sends `SIGINT` via a `libc` P/Invoke -(`kill(pid, 2)`), which the K4-proven headless host already turns into an -ACE-confirmed graceful logout. On Windows there is no equivalent today for a -console process with no message-pump window: `CloseMainWindow` is a no-op -for a console host (there is no `HWND` to target), and -`GenerateConsoleCtrlEvent` cannot usefully target an arbitrary child process -today — Windows delivers console control events to every process attached -to the SAME console as the calling process, so an unscoped call would also -signal the launcher itself (and anything else sharing that console), not -just the intended child. `TryRequestGracefulStop` therefore returns `false` -on Windows unconditionally, and `Stop` degrades straight to `CloseMainWindow` -(still a no-op for a console child) and then the timeout-driven `Kill()` — -exactly the hard-kill behavior this finding was written to describe, just -with a documented (rather than silent) gap. +**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts +`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and +the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`. +On Windows, console-capable launcher specs now use a narrow no-shell +`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and +an explicit inherited-handle list that preserves only redirected stdin plus +stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a +console for the creation transaction, detaches after the new group inherits +it, and later attaches only long enough to send +`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such +child is therefore both the root of its own process group and, for the normal +Explorer-launched case, attached to its own console. Graphical children opt +out and retain the ordinary `Process`/`WM_CLOSE` path. -**Known fix direction (not yet implemented).** Spawn the Windows child with -the `CREATE_NEW_PROCESS_GROUP` creation flag (available via a native -`CreateProcess` call or by setting it on the `ProcessStartInfo`/`Process` -plumbing in `SystemChildProcess`) so the child gets its own console process -group, detached from the launcher's own group. Then -`TryRequestGracefulStop` on Windows calls -`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)` — -`CTRL_BREAK` (unlike `CTRL_C`) can target a specific process group ID and, -unlike `CTRL_CLOSE`/`CTRL_LOGOFF`/`CTRL_SHUTDOWN`, is deliverable to a -process that has installed no console-control handler at all (the default -CRT handler treats it as a terminating signal, so `AcDream.Headless` doesn't -strictly need new code to receive SOME form of shutdown from it) — though -wiring a real `SetConsoleCtrlHandler` handler that routes `CTRL_BREAK` into -the same graceful-shutdown path K4 already built for Linux SIGINT is the -better long-term target, so a Windows headless launch gets the identical -ACE-confirmed graceful logout instead of just "exits somehow." +Two real Windows fixture gates cover both a console parent and a consoleless +WinExe parent. They prove exact complex argv, redirected stdin, receipt of a +targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`, +and a sibling process group that remains running until it receives its own +targeted break. Safe-handle cleanup, early-failure termination, and the +Linux SIGINT gate remain covered by the Launcher.Core suite. -**Acceptance for closing this issue:** `SystemChildProcess` spawns Windows -children with `CREATE_NEW_PROCESS_GROUP`; `TryRequestGracefulStop` sends -`CTRL_BREAK_EVENT` to that child's process group on Windows; a live -connected gate proves `AcDream.Headless` exits gracefully (ACE clears the -session immediately, not after the ~3-minute stale-session window) when -stopped via `LauncherProcessSupervisor.Stop` on Windows, matching the +**Acceptance for closing this issue:** automated process-group and targeted- +signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live +connected row proves `AcDream.Headless` exits gracefully and ACE clears the +session immediately (not after the ~3-minute stale-session window) when +stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the Linux SIGINT behavior. ## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 1d1f44de..c909820d 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -312,7 +312,10 @@ src/ AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner Profiles/ -> sole credential/profile document + CRUD owner - Launching/ -> config composition and supervised process seams + Launching/ -> config composition and supervised process seams; + Windows console hosts are no-shell, redirected- + stdin process-group leaders receiving targeted + CTRL_BREAK, while Linux hosts receive SIGINT Status/ -> incremental host-status parsing/tailing Orchestration/ -> immutable UI snapshots, typed actions, capability gates, and running-session lifetime diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 180d320f..76fd65bc 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -697,7 +697,10 @@ forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions. ## LA11 — closeout -- One connected-gate script `docs/research/2026-XX-XX-campaign-la-test-script.md` +- One exact operator script + `docs/research/2026-08-14-campaign-la-test-script.md`, fronted by the + connection-free `tools/run-campaign-la-preflight.ps1` and followed by + serial user rows, covering: all three launch modes vs local ACE, probe round-trip ×2 (no lingering session), char-select visual matrix + delete flow, login-commands + plugin behavior on both hosts, add-server/add-account purely in UI, @@ -729,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | -| LA11 | — | | | | +| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. | diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md new file mode 100644 index 00000000..664a569e --- /dev/null +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -0,0 +1,571 @@ +# Campaign LA11 — automated preflight and connected user gate + +**Status:** implementation checkpoint only. Run this script after the reviewed +LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA, +the Linux graphical client, and issue #397 remain open until the user records a +verdict for every applicable row below. + +This is the single Campaign LA operator script. The automated section is +display-free and connection-free. Rows A–I are deliberately manual and serial: +they use real retail DATs, a local ACE server, user-entered credentials, and +visual judgment that automation cannot supply. + +## 1. Safety boundary and required inputs + +Use placeholders throughout; never paste a password into a terminal, this +document, a screenshot, or a gate report. + +- ``: a clean Campaign LA worktree at the exact commit + under test. +- ``: a read-only source containing + `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and + `client_local_English.dat`. +- ``, ``, and ``: a local ACE endpoint and + account. Enter the account password only in the launcher's masked Password + field. The launcher intentionally stores it as plaintext in the **isolated** + `launcher-profiles.json`; children receive it through redirected stdin. +- ``: a second user-controlled character that can observe a + private `/tell` from each play mode. +- ``: a server-operator-approved disposable character. + Never substitute a primary character. If none exists, provision one with the + local server's normal admin procedure before row G. +- Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported + Vulkan Windows machine for rows A–H. Ubuntu x64 with PowerShell 7 and a Linux + desktop/WSLg is required for row I. The Avalonia launcher is supported on + Linux; `gui` and `guiSelect` **client** actions must remain disabled with the + Modern Runtime Slice-L explanation. + +Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher +before starting. Do not run another acdream gate in parallel. All generated +files must stay below one new gate directory; the canonical `%APPDATA%`, +`%LOCALAPPDATA%`, and XDG acdream roots are out of scope. + +## 2. Automated preflight — no UI, connection, credential, or bake + +In PowerShell 7 on Windows: + +```powershell +$Repo = [IO.Path]::GetFullPath('') +$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp" +$Preflight = Join-Path $Gate 'automated-preflight' +New-Item -ItemType Directory -Path $Gate | Out-Null + +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -OutputDirectory $Preflight + +$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw | + ConvertFrom-Json +if (-not $Report.success -or $Report.dirty) { + throw 'Stop: automated preflight failed or recorded a dirty worktree.' +} +if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) { + throw 'Stop: preflight HEAD does not equal the current HEAD.' +} +``` + +The expected matrix is: + +| Platform | Automated command group | Required result | Typical time | +|---|---|---|---:| +| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min | +| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min | +| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min | +| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min | +| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min | +| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min | +| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min | + +`report.json` records the tested HEAD/dirty state, OS/RID, exact commands, +durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight +plans 26 commands. It never launches App or Headless in connected mode and +never reads a credential. + +### Optional installed-DAT read-only row + +This is not a bake and must not replace row A. Add the switches below only when +the DAT directory may be read by tests: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') ` + -IncludeInstalledDat ` + -InstalledDatDirectory '' +``` + +The mandatory installed-DAT result is +`CharacterManagementLiveDatTests` with both `ACDREAM_PROBE_LIVE_MOUNT=1` and +`ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX +and fails if the test skipped or did anything other than pass. The action-map +and portal-asset probes are additional coverage, never substitutes. Expected +matrix size: 30 rows. + +On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository +path, and a Linux output path. Do not treat a Windows-hosted run over +`wsl.exe` as the Linux row. + +## 3. Prepare the deterministic local A/B feed + +Build distinct, version-stamped payloads so the staged launcher really changes +from A to B. These commands write only below `$Gate` (normal project `obj/bin` +incremental outputs are the already-authorized build outputs): + +```powershell +$VersionA = '1.0.1-la11.a' +$VersionB = '1.0.1-la11.b' +$Payloads = Join-Path $Gate 'update-payloads' +$Fixture = Join-Path $Gate 'update-fixture' + +function Publish-LaRelease([string]$Version, [string]$Label) { + $ClientWin = Join-Path $Payloads "$Label/client-win-x64" + $LauncherWin = Join-Path $Payloads "$Label/launcher-win-x64" + $ClientLinux = Join-Path $Payloads "$Label/client-linux-x64" + $LauncherLinux = Join-Path $Payloads "$Label/launcher-linux-x64" + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "App win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "Headless win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherWin --nologo + if ($LASTEXITCODE) { throw "Launcher win-x64 publish failed: $Label" } + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "App linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "Headless linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherLinux --nologo + if ($LASTEXITCODE) { throw "Launcher linux-x64 publish failed: $Label" } +} + +Publish-LaRelease $VersionA 'A' +Publish-LaRelease $VersionB 'B' + +pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1') ` + -OutputDirectory $Fixture ` + -ClientWinX64DirectoryA (Join-Path $Payloads 'A/client-win-x64') ` + -LauncherWinX64DirectoryA (Join-Path $Payloads 'A/launcher-win-x64') ` + -ClientLinuxX64DirectoryA (Join-Path $Payloads 'A/client-linux-x64') ` + -LauncherLinuxX64DirectoryA (Join-Path $Payloads 'A/launcher-linux-x64') ` + -ClientWinX64DirectoryB (Join-Path $Payloads 'B/client-win-x64') ` + -LauncherWinX64DirectoryB (Join-Path $Payloads 'B/launcher-win-x64') ` + -ClientLinuxX64DirectoryB (Join-Path $Payloads 'B/client-linux-x64') ` + -LauncherLinuxX64DirectoryB (Join-Path $Payloads 'B/launcher-linux-x64') +``` + +The helper rejects nonempty output, invalid or non-monotonic versions, missing +root executables, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs, +the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only +server, and an A/B selector. It does not download or mutate payload sources. + +Start the Windows loopback server without a shell or visible helper window: + +```powershell +$ServerInfo = [Diagnostics.ProcessStartInfo]::new() +$ServerInfo.FileName = (Get-Command pwsh).Source +$ServerInfo.UseShellExecute = $false +$ServerInfo.CreateNoWindow = $true +foreach ($Value in @( + '-NoProfile', '-File', (Join-Path $Fixture 'serve-fixture.ps1'), + '-Root', $Fixture, '-Port', '43119')) { + $ServerInfo.ArgumentList.Add($Value) +} +$FixtureServer = [Diagnostics.Process]::Start($ServerInfo) +$ManifestUri = 'http://127.0.0.1:43119/manifest.json' +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionA) { + throw 'Stop: local fixture did not begin on release A.' +} +``` + +## 4. Windows isolated launcher command and evidence rule + +```powershell +$WinRoot = Join-Path $Gate 'windows-roots' +$WinConfig = Join-Path $WinRoot 'config' +$WinData = Join-Path $WinRoot 'data' +$WinCache = Join-Path $WinRoot 'cache' +$Evidence = Join-Path $Gate 'evidence' +New-Item -ItemType Directory -Path $Evidence | Out-Null + +$LauncherA = Join-Path $Payloads 'A/launcher-win-x64/acdream-launcher.exe' +$LauncherArguments = @( + '--config-dir', $WinConfig, + '--data-dir', $WinData, + '--cache-dir', $WinCache, + '--update-manifest-uri', $ManifestUri) +& $LauncherA @LauncherArguments +``` + +All four options are process-local. The three roots are an indivisible set; +the local feed reaches only the updater and is never persisted. A self-update +must preserve the same validated suffix through helper and confirmation +restarts. The launcher, profiles, installer, current-version store, updater, +session composer, and orchestrator must all use this one exact path set. + +For every play/probe row, copy the session id shown in the launcher's Sessions +list into ``, then run: + +```powershell +$Status = Join-Path $WinCache 'launcher/sessions//status.jsonl' +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile $Status ` + -Mode '' ` + -ExpectedSessionId '' ` + -ReportPath (Join-Path $Evidence '-status.validation.json') +``` + +Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact +v1 fields **and property order**, one session id, UTC monotonic timestamps, +mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login +command failure, credential redaction, and no surviving App/Headless process. +Its report contains event names and a hash, not account, character, command, or +error payloads. Keep raw `session.json`/`status.jsonl` local; never upload them. + +## 5. Serial Windows user rows A–H + +### A — isolated first run, real DAT bake, and release-A client baseline + +1. Confirm the launcher opens First-run setup and all launch buttons are + unavailable. Save a redacted screenshot as `A-first-run-required.png`. +2. Enter `` in the wizard, select a sensible + worker count, and click **Validate**. Confirm all four DATs pass. +3. Click **Build and install**. Do not cancel or close the launcher. The real + bake may take 30–180 minutes. Confirm every phase reaches **Completed** and + the status says `Client content installed and verified. Launch is enabled.` +4. Open **Check for updates**. Confirm available release A, click **Install + client**, and wait for `Client update installed and activated.` Do not stage + launcher A; the test launcher already has version A. +5. Confirm these exact isolated artifacts exist and no `.previous-install` + remains after success: + +```powershell +$RequiredA = @( + (Join-Path $WinData 'install.json'), + (Join-Path $WinData 'pak/acdream.pak'), + (Join-Path $WinData 'app/current.json')) +foreach ($Path in $RequiredA) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" } +} +$RequiredA | ForEach-Object { + $Item = Get-Item -LiteralPath $_ + [ordered]@{ + name = $Item.Name + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant() + } +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json') +``` + +Do not copy `install.json` into shared evidence because it records the local DAT +path. Expected time: 45–200 minutes including bake. + +### B — server/account CRUD entirely through the UI + +1. Add `` at `127.0.0.1:`, edit its name + and port, then remove it. Confirm Cancel/Escape makes no mutation. +2. Add `` at `127.0.0.1:`. +3. Under it add `` with a user-invented throwaway field + value, edit its account name/value, then remove it. Do not reuse a real + password for this temporary row. +4. Add `` and enter its real password only in the masked field. +5. Close and reopen the launcher with the **same** `$LauncherArguments`. Confirm + only the real server/account persisted. Save redacted before/reopen images as + `B-crud-before-reopen.png` and `B-crud-after-reopen.png`. +6. Record only the profile file's size/hash, never its contents: + +```powershell +$Profile = Join-Path $WinConfig 'launcher-profiles.json' +$Item = Get-Item -LiteralPath $Profile +[ordered]@{ + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant() +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json') +``` + +Expected time: 10–15 minutes. + +### C — live character probe twice, no stale ACE session + +1. Select ``, click **Refresh characters**, and wait for the + probe row to finish. Confirm the roster appears without entering world. +2. Run the validator in `probe` mode for its session id. Confirm its exact + event order is `started, connected, characterList, disconnected, exited`, + with no `enteredWorld`, terminal code 0, and terminal reason `probe`. +3. In the ACE console/session administration view, confirm the account is no + longer logged in. Save a redacted `C-probe-1-ace-cleared.png`. +4. Repeat steps 1–3 immediately, producing a different session id, + `C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`. +5. Confirm Refresh is re-enabled and no `acdream-headless` process remains. + +Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure; +do not wait three minutes and call the next attempt a pass. + +### D — `guiSelect`, retail character screen, plugin, and login command + +1. Select one non-disposable roster character. Set its mode to `guiSelect`, + Plugins to exactly `acdream.smoke`, and its one login command to + `/tell , LA11-D-`. Save settings. +2. Click **GUI — character select**. Confirm the flat retail character list, + selection highlight, Enter button, Delete/Restore swap state, dialogs, and + absence of any invented rotating 3D preview. Save redacted + `D-character-select.png`. +3. Select the configured character and enter world. Confirm the observer gets + the exact D nonce once. Save `D-observer-tell.png` with names redacted. +4. Click **Stop** in the launcher. Confirm the game closes gracefully and ACE + releases the account. Validate `guiSelect` with + `-ExpectedPlugin acdream.smoke`. + +Expected time: 5–10 minutes. + +### E — direct `gui`, plugin, and login command + +1. Change the same character to `gui`, retain `acdream.smoke`, and change the + command nonce to `LA11-E-`. +2. Click **GUI — enter world**. Confirm it selects the exact cached character, + reaches the world, loads the plugin once, and the observer gets the E nonce + once. +3. Stop from the launcher, confirm ACE logout, and validate `gui` with the + expected plugin. Save `E-world.png`, `E-observer-tell.png`, and + `E-status.validation.json` with identifying text redacted. + +Expected time: 5–10 minutes. + +### F — headless, plugin/login command, and connected #397 acceptance + +1. Change the same character to `headless`, retain `acdream.smoke`, and use + `LA11-F-`. +2. Click **Headless**. Confirm `pluginLoaded(acdream.smoke)`, `enteredWorld`, + and the observer's single exact F nonce. +3. Click **Stop** once. On Windows this must target that child's distinct + process group with `CTRL_BREAK`; it must reach `disconnected` then + `exited(code:0, reason:graceful)` before the timeout, without a hard kill. + ACE must release the account immediately and the launcher must stay open. +4. Validate `headless` with the expected plugin and save + `F-status.validation.json` plus redacted ACE-clear evidence. + +The real automated fixture separately proves complex argv and redirected stdin +survive native `CreateProcessW`, the target receives `CTRL_BREAK`, a sibling +process group receives nothing, exit 0 precedes timeout, and `Kill` is never +called. This connected row proves the actual ACE graceful-logout half. Issue +#397 remains open if either half is missing. Expected time: 5–10 minutes. + +### G — disposable delete and restore + +1. Launch `guiSelect` for ``. Do not enter world. +2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click + Delete, inspect the retail confirmation dialog, cancel once, and confirm no + state change. +3. Delete again and confirm. Verify the wait dialog, greyed roster row/countdown, + disabled Enter/Delete, and enabled Restore. Save `G-deleted.png`. +4. Click Restore and confirm the same GUID returns to ordinary state with + Enter/Delete enabled and Restore disabled. Save `G-restored.png`. +5. Close through launcher **Stop**, confirm graceful terminal status and ACE + release. Validate with: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` + -Mode guiSelect ` + -ExpectNoEnteredWorld ` + -ExpectedSessionId '' ` + -ExpectedPlugin acdream.smoke ` + -ReportPath (Join-Path $Evidence 'G-status.validation.json') +``` + +If restore fails, stop the row, preserve evidence, and restore only that +disposable character with the server's normal admin recovery. Never continue +with another character. Expected time: 5–10 minutes. + +### H — local A→B client update, active-session refusal, rollback, self-update + +1. Record release A's `app/current.json`. Start one headless session and wait + for `enteredWorld`. +2. Switch the fixture atomically to B: + +```powershell +pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') ` + -Release B -Root $Fixture +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) { + throw 'Stop: fixture did not switch to B.' +} +``` + +3. Open **Check for updates** and **Check again**. While the session is active, + confirm Install client, Rollback client, and Stage launcher are disabled or + refuse without changing `app/current.json`. Save `H-active-refusal.png`. +4. Stop the headless session and validate its graceful status. Install client + B. Confirm `app/current.json` names B, A is previous, all installed-file + hashes verify, and new sessions resolve from the B directory. +5. Click **Rollback client**. Confirm A becomes current and B becomes previous. + Check again and install B once more, leaving B current. Save sanitized copies + of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and + `H-pointer-rollback-a.json`; they contain no credentials. +6. Click **Stage launcher**. Confirm restart is required, then close the + launcher normally. The copied helper must apply B and restart the launcher + with the same config/data/cache/feed suffix. +7. Confirm profiles, install record, and update state still come from the + isolated roots; `campaign-la-fixture-release.txt` beside the relaunched + executable says `release=B`; `launcher-update/pending.json` is gone; and no + transaction backup remains. Check again and confirm launcher B is current. + Save `H-self-update-confirmed.png` and a hash-only post-state inventory. + +Never edit a manifest to force this row and never point the launcher at a +non-loopback HTTP endpoint. Expected time: 15–30 minutes. + +## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots + +Stop the Windows launcher and fixture server only after every Windows session +is terminal: + +```powershell +if (-not $FixtureServer.HasExited) { + $FixtureServer.Kill() + $FixtureServer.WaitForExit() +} +``` + +In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository +and fixture may be read from a mounted Windows path, but roots must live on the +Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs +cannot escape the Linux environment: + +```powershell +$RepoLinux = [IO.Path]::GetFullPath('') +$FixtureLinux = [IO.Path]::GetFullPath('') +$PayloadsLinux = [IO.Path]::GetFullPath('') +$LinuxGate = [IO.Path]::GetFullPath('') +$env:XDG_CONFIG_HOME = Join-Path $LinuxGate 'xdg-config-home' +$env:XDG_DATA_HOME = Join-Path $LinuxGate 'xdg-data-home' +$env:XDG_CACHE_HOME = Join-Path $LinuxGate 'xdg-cache-home' +$LinuxConfig = Join-Path $env:XDG_CONFIG_HOME 'acdream' +$LinuxData = Join-Path $env:XDG_DATA_HOME 'acdream' +$LinuxCache = Join-Path $env:XDG_CACHE_HOME 'acdream' +$LinuxEvidence = Join-Path $LinuxGate 'evidence' +New-Item -ItemType Directory -Path $LinuxEvidence | Out-Null + +pwsh -NoProfile -File (Join-Path $FixtureLinux 'set-active-release.ps1') ` + -Release A -Root $FixtureLinux +``` + +Start `serve-fixture.ps1 -Root $FixtureLinux -Port 43119` in a dedicated native +terminal and leave it running. In another terminal: + +```powershell +$LauncherLinuxA = Join-Path $PayloadsLinux 'A/launcher-linux-x64/acdream-launcher' +& $LauncherLinuxA ` + --config-dir $LinuxConfig ` + --data-dir $LinuxData ` + --cache-dir $LinuxCache ` + --update-manifest-uri 'http://127.0.0.1:43119/manifest.json' +``` + +Complete this exact serial matrix: + +1. **Manual-DAT first run:** enter ``; + auto-detection may be empty by design. Validate, bake to + `$LinuxData/pak/acdream.pak`, verify, then install release-A client. +2. **CRUD:** add/edit/remove a temporary server and account entirely in the + launcher, then add the real Linux-reachable ACE profile. Enter its password + only in the masked field. Restart and confirm persistence. Run + `stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must + be `600`. +3. **Probe twice:** run Refresh twice, validate both status streams in `probe` + mode with native `pwsh`, and confirm ACE clears the account after each. +4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled + and show the explicit Modern Runtime Slice-L message. Do not bypass this + disablement and do not claim a Linux graphical-client gate. +5. **Headless:** configure `acdream.smoke` and + `/tell , LA11-I-`, launch, observe + the tell, click Stop, and validate `headless` + expected plugin. Native Linux + sends SIGINT and must reach graceful terminal status with no process leak. +6. **Update:** switch the native fixture to B, prove update actions refuse while + a headless session is active, stop it gracefully, install B, rollback to A, + reinstall B, stage launcher B, and close normally. Confirm the relaunched + binary's B marker, preserved explicit roots/feed, cleaned pending journal, + and executable owner bits on App, Headless, Launcher, and Bake. + +Copy only redacted screenshots, validation reports, pointer JSON, hashes, and +file-mode results into `$LinuxEvidence`. Keep the Linux profile and raw session +files local. Expected time: 60–220 minutes, dominated by the real bake. + +## 7. Evidence, redaction, verdict, and cleanup + +Expected evidence tree: + +```text +logs/campaign-la-user-gate-/ + automated-preflight/report.json + automated-preflight/commands/*.log + automated-preflight/publish/{win-x64,linux-x64}/... + update-fixture/fixture-report.json + update-fixture/{A,B}/manifest.json + evidence/A-install-hashes.json + evidence/B-*.png + evidence/C-probe-{1,2}-status.validation.json + evidence/D-*.png + D-status.validation.json + evidence/E-*.png + E-status.validation.json + evidence/F-*.png + F-status.validation.json + evidence/G-*.png + G-status.validation.json + evidence/H-*.png + H-pointer-*.json + evidence/I-*.png + I-status.validation.json + I-modes.txt + verdict.json +``` + +Before sharing evidence: + +- remove or mask account names, character names, DAT paths, hostnames other than + loopback, and server-admin identifiers from screenshots; +- never copy `launcher-profiles.json`, raw session configs/status streams, + stdout/stderr that may contain user text, or environment values; +- search the shareable evidence for the exact user-entered password and any + gate-only sentinel secret; the match count must be zero; +- retain SHA-256 and sizes so local raw artifacts remain auditable. + +No additional raw child/plugin diagnostic sink is required: `pluginLoaded`, +the strict terminal status, the observer's redacted tell evidence, and the +automated targeted-signal fixture cover the acceptance questions without +capturing credentials or arbitrary chat. + +Create `verdict.json` manually with schema version 1, exact tested HEAD, rows +A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and +the user's overall verdict. Row I is not applicable only when no native +Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the +current Linux requirement, so it cannot be silently omitted. + +Cleanup is graceful-first and serial: + +1. Restore `` and verify it is ordinary before closing + its session. +2. Stop every launcher session once; require a passing validator and ACE-clear + observation. If a child survives the timeout, record the gate failure and + its PID before any emergency termination. +3. Close each launcher normally, then stop only the fixture-server process + created above. Do not kill ACE as a substitute for logout evidence. +4. Leave update pointers on B or roll the **isolated** client back to A through + the UI; never edit pointers or journals by hand. +5. Remove the real account through the isolated launcher UI. After review, + delete only the explicitly recorded `$WinConfig`/`$LinuxConfig` gate roots + that held plaintext passwords, or change the test account password. Do not + recursively delete a computed, empty, canonical, home, repository, or XDG + parent path. +6. Preserve the redacted evidence and reports. The large isolated pak/payload + trees may be removed only after resolving and checking their full paths are + descendants of the recorded gate roots. + +Estimated total: 3–7 hours, primarily the two real DAT bakes and full serial +test suites. A failure stops the current row; restore/stop/collect evidence, +then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped +until the user accepts the complete applicable matrix. diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index db477433..dbd662b3 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -331,7 +331,9 @@ the process supervisor executes. Campaign V); visuals settle at the user gate. - **Headless plugin host:** fixture plugin in the Headless suite (load, capability flag, teardown). -- **Connected gates (user-driven):** every launch mode against local ACE +- **Connected gates (user-driven):** execute the exact serial matrix in + `docs/research/2026-08-14-campaign-la-test-script.md` only after its + connection-free automated preflight passes. Cover every launch mode against local ACE (gui / guiSelect / headless), the character probe (fresh account → refresh → roster appears, and repeated probes leaving no stale ACE session), clean-profile first-run wizard end-to-end, staged-manifest diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs index 872c0130..b8816ab7 100644 --- a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -41,11 +41,10 @@ public interface ILauncherChildProcess : IDisposable /// documented project landmine; see CLAUDE.md /// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved /// the headless host's SIGINT handler produces an ACE-confirmed - /// graceful logout). On Windows there is no reliable cross-console - /// mechanism for an arbitrary no-window child process today — see - /// docs/ISSUES.md for the tracked gap and fix direction; this - /// returns false there. Returns true only when the signal was - /// actually delivered; never throws. + /// graceful logout). On Windows, console-capable children are started + /// as distinct process-group leaders and receive a targeted + /// CTRL_BREAK_EVENT. Returns true only when the signal was actually + /// delivered; never throws. /// bool TryRequestGracefulStop(); @@ -72,7 +71,9 @@ public interface ILauncherChildProcessFactory public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory { public ILauncherChildProcess Create(LauncherProcessSpec spec) => - new SystemChildProcess(spec); + OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop + ? new WindowsSystemChildProcess(spec) + : new SystemChildProcess(spec); } internal sealed partial class SystemChildProcess : ILauncherChildProcess @@ -86,11 +87,13 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess private static partial int kill(int pid, int sig); private readonly Process _process; + private readonly bool _supportsConsoleGracefulStop; private bool _raisingEnabled; internal SystemChildProcess(LauncherProcessSpec spec) { ArgumentNullException.ThrowIfNull(spec); + _supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop; var startInfo = new ProcessStartInfo { @@ -130,11 +133,11 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess public bool TryRequestGracefulStop() { - if (!OperatingSystem.IsLinux()) + if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop) { - // No reliable cross-console mechanism exists for an - // arbitrary no-window Windows child process — tracked gap, - // see docs/ISSUES.md. + // Windows console-capable children use + // WindowsSystemChildProcess. Graphical/non-console children + // deliberately retain the Process/WM_CLOSE path. return false; } diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs index 11b59dee..599a932d 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs @@ -7,9 +7,13 @@ namespace AcDream.Launcher.Core.Launching; /// Deliberately carries no credential field — the password is a separate /// transient parameter to /// that flows only to the child's stdin, never into this spec, an -/// argument list, or a process environment. +/// argument list, or a process environment. Console-capable specs set +/// so Windows starts them +/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and +/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE. /// public sealed record LauncherProcessSpec( string ExecutablePath, IReadOnlyList Arguments, - string? WorkingDirectory = null); + string? WorkingDirectory = null, + bool SupportsConsoleGracefulStop = true); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs index 3a4c2923..252847c5 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -171,8 +171,8 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor /// /// Requests a graceful stop — first /// (SIGINT - /// on Linux; a no-op on Windows today, see - /// 's docs), + /// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console + /// children), /// then — falling /// back to if the process has /// not exited within . A no-op if diff --git a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs new file mode 100644 index 00000000..00138c30 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs @@ -0,0 +1,843 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Windows launcher child created without a shell as a true console process- +/// group leader. The native start is deliberately narrow: it exists only +/// because does not expose +/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin. +/// +internal sealed class WindowsSystemChildProcess : ILauncherChildProcess +{ + private readonly LauncherProcessSpec _spec; + private readonly IWindowsConsoleControl _consoleControl; + private Process? _process; + private TextWriter? _standardInput; + private int _processGroupId; + private bool _raisingEnabled; + + internal WindowsSystemChildProcess( + LauncherProcessSpec spec, + IWindowsConsoleControl? consoleControl = null) + { + _spec = spec ?? throw new ArgumentNullException(nameof(spec)); + _consoleControl = consoleControl ?? WindowsConsoleControl.Instance; + } + + public bool HasExited => RequireProcess().HasExited; + + public int ExitCode => RequireProcess().ExitCode; + + public TextWriter StandardInput => _standardInput + ?? throw new InvalidOperationException("The child process has not started."); + + public event EventHandler? Exited; + + public void Start() + { + if (_process is not null) + { + throw new InvalidOperationException("The child process already started."); + } + + WindowsProcessStartResult started = WindowsProcessNative.Start(_spec); + try + { + _process = Process.GetProcessById(started.ProcessId); + _process.EnableRaisingEvents = true; + _process.Exited += OnExited; + _raisingEnabled = true; + _standardInput = started.TakeStandardInput(); + _processGroupId = started.ProcessId; + started.Resume(); + } + catch + { + started.Terminate(); + _standardInput?.Dispose(); + _standardInput = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + + throw; + } + finally + { + started.Dispose(); + } + } + + public bool TryRequestGracefulStop() + { + try + { + if (!_spec.SupportsConsoleGracefulStop + || _process is not { HasExited: false } process + || _processGroupId <= 0) + { + return false; + } + + return _consoleControl.TrySendBreak(process.Id, _processGroupId); + } + catch + { + // The process may have exited between the state check and the + // control request. Graceful-stop attempts never escape Stop(). + return false; + } + } + + public bool CloseMainWindow() => RequireProcess().CloseMainWindow(); + + public void Kill() => RequireProcess().Kill(entireProcessTree: true); + + public bool WaitForExit(TimeSpan timeout) => RequireProcess().WaitForExit(timeout); + + public void Dispose() + { + _standardInput?.Dispose(); + _standardInput = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + } + + private Process RequireProcess() => _process + ?? throw new InvalidOperationException("The child process has not started."); + + private void OnExited(object? sender, EventArgs e) => + Exited?.Invoke(this, EventArgs.Empty); +} + +internal interface IWindowsConsoleControl +{ + bool TrySendBreak(int childProcessId, int childProcessGroupId); +} + +internal sealed class WindowsConsoleControl : IWindowsConsoleControl +{ + private const uint CtrlBreakEvent = 1; + + internal static WindowsConsoleControl Instance { get; } = new(); + + private WindowsConsoleControl() + { + } + + public bool TrySendBreak(int childProcessId, int childProcessGroupId) + { + if (!OperatingSystem.IsWindows() + || childProcessId <= 0 + || childProcessGroupId <= 0) + { + return false; + } + + lock (WindowsConsoleSynchronization.Gate) + { + bool attachedHere = false; + try + { + uint[] processes = new uint[1]; + if (Native.GetConsoleProcessList(processes, 1) == 0) + { + if (!Native.AttachConsole((uint)childProcessId)) + { + return false; + } + + attachedHere = true; + } + + return Native.GenerateConsoleCtrlEvent( + CtrlBreakEvent, + (uint)childProcessGroupId); + } + catch + { + return false; + } + finally + { + if (attachedHere) + { + _ = Native.FreeConsole(); + } + } + } + } + + private static class Native + { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool AttachConsole(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GenerateConsoleCtrlEvent( + uint controlEvent, + uint processGroupId); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + } +} + +/// +/// A process can be attached to only one console. Child creation and targeted +/// control-event attachment therefore share one process-wide gate. +/// +internal static class WindowsConsoleSynchronization +{ + internal static object Gate { get; } = new(); +} + +internal sealed class WindowsProcessStartResult : IDisposable +{ + private readonly SafeKernelHandle _processHandle; + private readonly SafeKernelHandle _threadHandle; + private SafeFileHandle? _standardInput; + private bool _resumed; + + internal WindowsProcessStartResult( + int processId, + SafeKernelHandle processHandle, + SafeKernelHandle threadHandle, + SafeFileHandle standardInput) + { + ProcessId = processId; + _processHandle = processHandle; + _threadHandle = threadHandle; + _standardInput = standardInput; + } + + internal int ProcessId { get; } + + internal TextWriter TakeStandardInput() + { + SafeFileHandle handle = _standardInput + ?? throw new InvalidOperationException("Standard input was already claimed."); + var stream = new FileStream(handle, FileAccess.Write, 4096, isAsync: false); + _standardInput = null; + try + { + return new StreamWriter( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true, + }; + } + catch + { + stream.Dispose(); + throw; + } + } + + internal void Resume() + { + if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be resumed."); + } + + _resumed = true; + } + + internal void Terminate() + { + if (!_processHandle.IsInvalid) + { + _ = WindowsProcessNative.TerminateProcess(_processHandle, 74); + } + } + + public void Dispose() + { + if (!_resumed) + { + Terminate(); + } + + _standardInput?.Dispose(); + _threadHandle.Dispose(); + _processHandle.Dispose(); + } +} + +internal static class WindowsProcessNative +{ + private const uint CreateSuspended = 0x00000004; + private const uint CreateNewProcessGroup = 0x00000200; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint StartfUseStdHandles = 0x00000100; + private const short SwHide = 0; + private const uint HandleFlagInherit = 0x00000001; + private const uint DuplicateSameAccess = 0x00000002; + private const uint GenericWrite = 0x40000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x00000080; + private const int StdOutputHandle = -11; + private const int StdErrorHandle = -12; + private static readonly IntPtr ProcThreadAttributeHandleList = new(0x00020002); + + internal static WindowsProcessStartResult Start(LauncherProcessSpec spec) + { + ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath); + ArgumentNullException.ThrowIfNull(spec.Arguments); + + lock (WindowsConsoleSynchronization.Gate) + { + bool allocatedConsole = false; + try + { + // An Avalonia launcher started from Explorer has no console. + // CREATE_NEW_PROCESS_GROUP alone does not allocate one, and a + // console-less group cannot receive GenerateConsoleCtrlEvent. + // Allocate one only for the creation transaction, hide it, + // let the group leader inherit it, then detach the launcher. + // Each such child consequently owns a distinct console as + // well as a distinct process group. + if (!HasConsole()) + { + if (!AllocConsole()) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher could not allocate the child console."); + } + + allocatedConsole = true; + IntPtr consoleWindow = GetConsoleWindow(); + if (consoleWindow != IntPtr.Zero) + { + _ = ShowWindow(consoleWindow, SwHide); + } + } + + return StartCore(spec); + } + finally + { + if (allocatedConsole) + { + _ = FreeConsole(); + } + } + } + } + + private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec) + { + SafeFileHandle? parentInput = null; + try + { + using SafeFileHandle childInput = CreateChildInputPipe( + out SafeFileHandle createdParentInput); + parentInput = createdParentInput; + using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle); + using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle); + using var attributes = new ProcessThreadAttributeList( + childInput.DangerousGetHandle(), + childOutput.DangerousGetHandle(), + childError.DangerousGetHandle()); + + var startup = new StartupInfoEx + { + StartupInfo = new StartupInfo + { + Size = Marshal.SizeOf(), + Flags = StartfUseStdHandles, + StandardInput = childInput.DangerousGetHandle(), + StandardOutput = childOutput.DangerousGetHandle(), + StandardError = childError.DangerousGetHandle(), + }, + AttributeList = attributes.Pointer, + }; + string executable = ResolveExecutable(spec.ExecutablePath); + string commandLineText = BuildCommandLine(executable, spec.Arguments); + var commandLine = new StringBuilder(commandLineText, commandLineText.Length + 1); + string? workingDirectory = string.IsNullOrWhiteSpace(spec.WorkingDirectory) + ? null + : Path.GetFullPath(spec.WorkingDirectory); + + if (!CreateProcessW( + executable, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + inheritHandles: true, + CreateSuspended | CreateNewProcessGroup | ExtendedStartupInfoPresent, + IntPtr.Zero, + workingDirectory, + ref startup, + out ProcessInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be created."); + } + + var processHandle = new SafeKernelHandle( + information.Process, + ownsHandle: true); + var threadHandle = new SafeKernelHandle( + information.Thread, + ownsHandle: true); + try + { + var result = new WindowsProcessStartResult( + checked((int)information.ProcessId), + processHandle, + threadHandle, + parentInput); + parentInput = null; + return result; + } + catch + { + _ = TerminateProcess(processHandle, 74); + threadHandle.Dispose(); + processHandle.Dispose(); + throw; + } + } + finally + { + parentInput?.Dispose(); + } + } + + private static bool HasConsole() + { + uint[] processes = new uint[1]; + return GetConsoleProcessList(processes, 1) != 0; + } + + internal static uint ResumeThread(SafeKernelHandle thread) => + NativeResumeThread(thread); + + internal static bool TerminateProcess(SafeKernelHandle process, uint exitCode) => + NativeTerminateProcess(process, exitCode); + + internal static string BuildCommandLine( + string executable, + IReadOnlyList arguments) + { + var builder = new StringBuilder(); + AppendQuotedArgument(builder, executable); + foreach (string argument in arguments) + { + ArgumentNullException.ThrowIfNull(argument); + builder.Append(' '); + AppendQuotedArgument(builder, argument); + } + + return builder.ToString(); + } + + private static void AppendQuotedArgument(StringBuilder builder, string value) + { + builder.Append('"'); + int backslashes = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + { + builder.Append('\\', backslashes * 2 + 1); + builder.Append('"'); + backslashes = 0; + continue; + } + + builder.Append('\\', backslashes); + backslashes = 0; + builder.Append(character); + } + + builder.Append('\\', backslashes * 2); + builder.Append('"'); + } + + private static SafeFileHandle CreateChildInputPipe(out SafeFileHandle parentInput) + { + var security = new SecurityAttributes + { + Length = Marshal.SizeOf(), + InheritHandle = true, + }; + if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child stdin pipe could not be created."); + } + + var child = new SafeFileHandle(read, ownsHandle: true); + parentInput = new SafeFileHandle(write, ownsHandle: true); + if (!SetHandleInformation( + parentInput, + HandleFlagInherit, + 0)) + { + int error = Marshal.GetLastWin32Error(); + child.Dispose(); + parentInput.Dispose(); + throw new Win32Exception(error, + "The launcher child stdin pipe could not be isolated."); + } + + return child; + } + + private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle) + { + IntPtr source = GetStdHandle(standardHandle); + if (source != IntPtr.Zero && source != new IntPtr(-1)) + { + IntPtr current = GetCurrentProcess(); + if (DuplicateHandle( + current, + source, + current, + out IntPtr duplicate, + 0, + inheritHandle: true, + DuplicateSameAccess)) + { + return new SafeKernelHandle(duplicate, ownsHandle: true); + } + } + + IntPtr nul = CreateFileW( + "NUL", + GenericWrite, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileAttributeNormal, + IntPtr.Zero); + if (nul == IntPtr.Zero || nul == new IntPtr(-1)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child fallback output handle could not be opened."); + } + + var handle = new SafeKernelHandle(nul, ownsHandle: true); + if (!SetHandleInformation(handle, HandleFlagInherit, HandleFlagInherit)) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, + "The launcher child fallback output handle could not be inherited."); + } + + return handle; + } + + private static string ResolveExecutable(string executable) + { + if (Path.IsPathFullyQualified(executable)) + { + return Path.GetFullPath(executable); + } + + var buffer = new StringBuilder(32_768); + uint length = SearchPathW( + null, + executable, + null, + (uint)buffer.Capacity, + buffer, + IntPtr.Zero); + if (length == 0 || length >= buffer.Capacity) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Launcher child executable '{executable}' was not found."); + } + + return Path.GetFullPath(buffer.ToString()); + } + + private sealed class ProcessThreadAttributeList : IDisposable + { + private IntPtr _pointer; + private IntPtr _handles; + private bool _initialized; + + internal ProcessThreadAttributeList(params IntPtr[] handles) + { + nuint size = 0; + _ = InitializeProcThreadAttributeList( + IntPtr.Zero, + 1, + 0, + ref size); + _pointer = Marshal.AllocHGlobal(checked((nint)size)); + if (!InitializeProcThreadAttributeList(_pointer, 1, 0, ref size)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child handle list could not be initialized."); + } + _initialized = true; + + _handles = Marshal.AllocHGlobal(handles.Length * IntPtr.Size); + for (int index = 0; index < handles.Length; index++) + { + Marshal.WriteIntPtr(_handles, index * IntPtr.Size, handles[index]); + } + + if (!UpdateProcThreadAttribute( + _pointer, + 0, + ProcThreadAttributeHandleList, + _handles, + checked((nuint)(handles.Length * IntPtr.Size)), + IntPtr.Zero, + IntPtr.Zero)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child inherited-handle list could not be set."); + } + } + + internal IntPtr Pointer => _pointer; + + public void Dispose() + { + if (_pointer != IntPtr.Zero) + { + if (_initialized) + { + DeleteProcThreadAttributeList(_pointer); + _initialized = false; + } + Marshal.FreeHGlobal(_pointer); + _pointer = IntPtr.Zero; + } + + if (_handles != IntPtr.Zero) + { + Marshal.FreeHGlobal(_handles); + _handles = IntPtr.Zero; + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal IntPtr SecurityDescriptor; + [MarshalAs(UnmanagedType.Bool)] internal bool InheritHandle; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + internal int Size; + internal string? Reserved; + internal string? Desktop; + internal string? Title; + internal int X; + internal int Y; + internal int XSize; + internal int YSize; + internal int XCountChars; + internal int YCountChars; + internal int FillAttribute; + internal uint Flags; + internal short ShowWindow; + internal short Reserved2Size; + internal IntPtr Reserved2; + internal IntPtr StandardInput; + internal IntPtr StandardOutput; + internal IntPtr StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx + { + internal StartupInfo StartupInfo; + internal IntPtr AttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + internal IntPtr Process; + internal IntPtr Thread; + internal uint ProcessId; + internal uint ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + string? currentDirectory, + ref StartupInfoEx startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreatePipe( + out IntPtr readPipe, + out IntPtr writePipe, + ref SecurityAttributes pipeAttributes, + uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation( + SafeHandle handle, + uint mask, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr window, int commandShow); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int standardHandle); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DuplicateHandle( + IntPtr sourceProcess, + IntPtr sourceHandle, + IntPtr targetProcess, + out IntPtr targetHandle, + uint desiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + uint options); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint SearchPathW( + string? path, + string fileName, + string? extension, + uint bufferLength, + StringBuilder buffer, + IntPtr filePart); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList( + IntPtr attributeList, + int attributeCount, + int flags, + ref nuint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + IntPtr attributeList, + uint flags, + IntPtr attribute, + IntPtr value, + nuint size, + IntPtr previousValue, + IntPtr returnSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + + [DllImport("kernel32.dll", EntryPoint = "ResumeThread", SetLastError = true)] + private static extern uint NativeResumeThread(SafeKernelHandle thread); + + [DllImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool NativeTerminateProcess( + SafeKernelHandle process, + uint exitCode); +} + +internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + internal SafeKernelHandle(IntPtr handle, bool ownsHandle) + : base(ownsHandle) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() => CloseHandle(handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index 4c3ffe16..5a98b795 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -104,7 +104,8 @@ public sealed class LauncherExecutableSet : new LauncherProcessSpec( paths.GraphicalHostPath, ["--session-config", configFilePath], - paths.WorkingDirectory); + paths.WorkingDirectory, + SupportsConsoleGracefulStop: false); } public LauncherProcessSpec CreateProbeSpec(string configFilePath) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 350260b2..1b02f993 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -28,6 +28,10 @@ + + + + diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs new file mode 100644 index 00000000..1f1c9164 --- /dev/null +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -0,0 +1,248 @@ +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher; + +internal enum LauncherStartupMode +{ + Desktop, + VerifyPublish, + SelfUpdateHelper, + SelfUpdateConfirmation, +} + +/// +/// Immutable, process-local launcher inputs. Parsing happens before any +/// launcher owner is constructed so every owner receives the same exact path +/// set and the test-feed URI can reach only the updater composition. +/// +internal sealed class LauncherStartupOptions +{ + private readonly IReadOnlyList _publicArguments; + + private LauncherStartupOptions( + LauncherStartupMode mode, + ApplicationPathSet paths, + Uri updateManifestUri, + IReadOnlyList publicArguments) + { + Mode = mode; + Paths = paths; + UpdateManifestUri = updateManifestUri; + _publicArguments = Array.AsReadOnly(publicArguments.ToArray()); + } + + internal LauncherStartupMode Mode { get; } + + internal ApplicationPathSet Paths { get; } + + internal Uri UpdateManifestUri { get; } + + /// + /// The validated public option suffix. LA10 passes this suffix through its + /// helper and confirmation processes so an isolated self-update cannot + /// fall back to canonical user roots or the production feed. + /// + internal IReadOnlyList PublicArguments => _publicArguments; + + internal static LauncherStartupOptions Parse( + IReadOnlyList arguments, + Func? resolveDefaultPaths = null) + { + ArgumentNullException.ThrowIfNull(arguments); + resolveDefaultPaths ??= () => ApplicationPathSet.Resolve(); + + (LauncherStartupMode mode, int publicStart) = ReadMode(arguments); + string[] publicArguments = arguments.Skip(publicStart).ToArray(); + + if (publicArguments.Contains("--verify-publish", StringComparer.Ordinal)) + { + if (mode != LauncherStartupMode.Desktop + || publicArguments.Length != 1 + || !string.Equals( + publicArguments[0], + "--verify-publish", + StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + "--verify-publish must be the only launcher argument."); + } + + return new LauncherStartupOptions( + LauncherStartupMode.VerifyPublish, + // The publish probe returns before this value is observed. A + // non-resolving sentinel keeps the probe display- and + // user-profile-free even under a deliberately broken runtime. + new ApplicationPathSet(string.Empty, string.Empty, string.Empty, null), + ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + string? configDirectory = null; + string? dataDirectory = null; + string? cacheDirectory = null; + Uri? updateManifestUri = null; + + for (int index = 0; index < publicArguments.Length; index += 2) + { + string name = publicArguments[index]; + if (index + 1 >= publicArguments.Length + || publicArguments[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a value."); + } + + string value = publicArguments[index + 1]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a non-empty value."); + } + + switch (name) + { + case "--config-dir": + SetDirectoryOnce(ref configDirectory, value, name); + break; + case "--data-dir": + SetDirectoryOnce(ref dataDirectory, value, name); + break; + case "--cache-dir": + SetDirectoryOnce(ref cacheDirectory, value, name); + break; + case "--update-manifest-uri": + if (updateManifestUri is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)) + { + throw new LauncherStartupOptionsException( + "--update-manifest-uri must be an absolute URI."); + } + + if (parsed.Scheme != Uri.UriSchemeHttps + && !(parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI must use HTTPS " + + "(loopback HTTP is test-only)."); + } + + if (!string.IsNullOrEmpty(parsed.UserInfo)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain user information."); + } + + updateManifestUri = parsed; + break; + default: + throw new LauncherStartupOptionsException( + $"Unknown launcher option '{name}'."); + } + } + + int suppliedRoots = new[] { configDirectory, dataDirectory, cacheDirectory } + .Count(path => path is not null); + if (suppliedRoots is > 0 and < 3) + { + throw new LauncherStartupOptionsException( + "--config-dir, --data-dir, and --cache-dir must be supplied together."); + } + + ApplicationPathSet paths = suppliedRoots == 3 + ? new ApplicationPathSet( + configDirectory!, + dataDirectory!, + cacheDirectory!, + LegacyConfigDirectory: null) + : resolveDefaultPaths(); + return new LauncherStartupOptions( + mode, + paths, + updateManifestUri ?? ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + private static (LauncherStartupMode Mode, int PublicStart) ReadMode( + IReadOnlyList arguments) + { + if (arguments.Count == 0) + { + return (LauncherStartupMode.Desktop, 0); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.HelperArgument, + StringComparison.Ordinal)) + { + // Malformed internal invocations are rejected by the bootstrap + // with EX_USAGE. Do not reinterpret their operands as public + // options while resolving the manager they need to report that. + return ( + LauncherStartupMode.SelfUpdateHelper, + arguments.Count >= 4 ? 4 : arguments.Count); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.ConfirmArgument, + StringComparison.Ordinal)) + { + return ( + LauncherStartupMode.SelfUpdateConfirmation, + arguments.Count >= 2 ? 2 : arguments.Count); + } + + return (LauncherStartupMode.Desktop, 0); + } + + private static void SetDirectoryOnce( + ref string? destination, + string value, + string option) + { + if (destination is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Path.IsPathFullyQualified(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' must be an absolute path."); + } + + try + { + destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value)); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' is not a valid absolute path.", + ex); + } + } +} + +internal sealed class LauncherStartupOptionsException : Exception +{ + internal LauncherStartupOptionsException(string message) + : base(message) + { + } + + internal LauncherStartupOptionsException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj new file mode 100644 index 00000000..e5c9a2c7 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + enable + latest + true + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs new file mode 100644 index 00000000..dc047cdf --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs @@ -0,0 +1,54 @@ +using System.Text.Json; + +if (args.Length < 4 + || args[0] != "wait-for-break" + || string.IsNullOrWhiteSpace(args[1]) + || string.IsNullOrWhiteSpace(args[2])) +{ + return 64; +} + +string readyPath = Path.GetFullPath(args[1]); +string breakPath = Path.GetFullPath(args[2]); +string label = args[3]; +string[] payloadArguments = args.Skip(4).ToArray(); +using var stopped = new ManualResetEventSlim(false); +ConsoleCancelEventHandler handler = (_, eventArgs) => +{ + if (eventArgs.SpecialKey != ConsoleSpecialKey.ControlBreak) + { + return; + } + + eventArgs.Cancel = true; + try + { + File.WriteAllText(breakPath, label); + } + finally + { + stopped.Set(); + } +}; +Console.CancelKeyPress += handler; +try +{ + string stdin = Console.In.ReadToEnd(); + Directory.CreateDirectory(Path.GetDirectoryName(readyPath)!); + File.WriteAllText( + readyPath, + JsonSerializer.Serialize(new + { + processId = Environment.ProcessId, + label, + arguments = payloadArguments, + stdinLength = stdin.Length, + stdinLineCount = stdin.Count(character => character == '\n'), + })); + + return stopped.Wait(TimeSpan.FromSeconds(30)) ? 0 : 75; +} +finally +{ + Console.CancelKeyPress -= handler; +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj new file mode 100644 index 00000000..441bc38a --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj @@ -0,0 +1,14 @@ + + + WinExe + net10.0 + enable + enable + latest + true + true + + + + + diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs new file mode 100644 index 00000000..22ad9230 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/Program.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using AcDream.Launcher.Core.Launching; + +if (args.Length != 3) +{ + return 64; +} + +string resultPath = Path.GetFullPath(args[0]); +string dotnetPath = args[1]; +string childAssembly = Path.GetFullPath(args[2]); +string root = Path.Combine( + Path.GetDirectoryName(resultPath)!, + "consoleless-children"); +Directory.CreateDirectory(root); +string targetReady = Path.Combine(root, "target.ready.json"); +string targetBreak = Path.Combine(root, "target.break"); +string siblingReady = Path.Combine(root, "sibling.ready.json"); +string siblingBreak = Path.Combine(root, "sibling.break"); + +bool parentHadConsoleBefore = HasConsole(); +using var target = new LauncherProcessSupervisor(); +using var sibling = new LauncherProcessSupervisor(); +try +{ + target.Start( + Spec(dotnetPath, childAssembly, targetReady, targetBreak, "target"), + "stdin-from-consoleless-parent"); + sibling.Start( + Spec(dotnetPath, childAssembly, siblingReady, siblingBreak, "sibling"), + password: null); + WaitForFile(targetReady, target); + WaitForFile(siblingReady, sibling); + bool parentHadConsoleAfterStarts = HasConsole(); + + target.Stop(TimeSpan.FromSeconds(10)); + bool siblingUnaffected = sibling.State != LauncherSessionState.Exited + && !File.Exists(siblingBreak); + sibling.Stop(TimeSpan.FromSeconds(10)); + + Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!); + File.WriteAllText( + resultPath, + JsonSerializer.Serialize(new + { + parentHadConsoleBefore, + parentHadConsoleAfterStarts, + targetExitCode = target.ExitCode, + siblingExitCode = sibling.ExitCode, + targetBreakObserved = File.Exists(targetBreak), + siblingBreakObserved = File.Exists(siblingBreak), + siblingUnaffected, + })); + return 0; +} +catch (Exception error) +{ + Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!); + File.WriteAllText( + resultPath, + JsonSerializer.Serialize(new + { + parentHadConsoleBefore, + error = error.GetType().Name + ": " + error.Message, + })); + return 1; +} +finally +{ + ForceStop(target); + ForceStop(sibling); +} + +static LauncherProcessSpec Spec( + string dotnetPath, + string childAssembly, + string ready, + string breakMarker, + string label) => + new( + dotnetPath, + [ + childAssembly, + "wait-for-break", + ready, + breakMarker, + label, + "argument with spaces", + ]); + +static void WaitForFile(string path, LauncherProcessSupervisor supervisor) +{ + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (!File.Exists(path)) + { + if (supervisor.State == LauncherSessionState.Exited) + { + throw new InvalidOperationException( + $"Child exited early with {supervisor.ExitCode}."); + } + + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException("Child did not become ready."); + } + + Thread.Sleep(20); + } +} + +static void ForceStop(LauncherProcessSupervisor supervisor) +{ + try + { + supervisor.Stop(TimeSpan.Zero); + } + catch + { + } +} + +static bool HasConsole() +{ + uint[] processes = new uint[1]; + return Native.GetConsoleProcessList(processes, 1) != 0; +} + +internal static partial class Native +{ + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); +} diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj index 78fa3e4f..f1677cc9 100644 --- a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj +++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj @@ -25,5 +25,17 @@ false true + + + false + true + + + + false + true + diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index 4ec1431e..ca55ff60 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; using System.Threading; using AcDream.Launcher.Core.Launching; @@ -6,6 +8,27 @@ namespace AcDream.Launcher.Core.Tests.Launching; public sealed class LauncherProcessSupervisorTests { + [Fact] + public void WindowsFactoryUsesNativeProcessGroupsOnlyForConsoleChildren() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var factory = new SystemChildProcessFactory(); + using ILauncherChildProcess console = factory.Create( + new LauncherProcessSpec("headless.exe", [])); + using ILauncherChildProcess graphical = factory.Create( + new LauncherProcessSpec( + "graphical.exe", + [], + SupportsConsoleGracefulStop: false)); + + Assert.IsType(console); + Assert.IsType(graphical); + } + [Fact] public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning() { @@ -197,6 +220,158 @@ public sealed class LauncherProcessSupervisorTests } } + [Fact] + public async Task WindowsCtrlBreakStopsOnlyTheTargetProcessGroupWithoutKill() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + "acdream-la11-ctrl-break", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var targetFactory = new RecordingRealChildProcessFactory(); + var siblingFactory = new RecordingRealChildProcessFactory(); + using var target = new LauncherProcessSupervisor(targetFactory); + using var sibling = new LauncherProcessSupervisor(siblingFactory); + string targetReady = Path.Combine(root, "target.ready.json"); + string targetBreak = Path.Combine(root, "target.break"); + string siblingReady = Path.Combine(root, "sibling.ready.json"); + string siblingBreak = Path.Combine(root, "sibling.break"); + string[] exactArguments = + [ + "plain", + "contains spaces", + "quoted-\"value", + "ends-with-backslash\\", + string.Empty, + ]; + + try + { + target.Start( + ConsoleFixtureSpec( + targetReady, + targetBreak, + "target", + exactArguments), + "fixture-input"); + sibling.Start( + ConsoleFixtureSpec( + siblingReady, + siblingBreak, + "sibling", + ["sibling"]), + password: null); + await WaitForFileAsync(targetReady, targetFactory.LastCreated!); + await WaitForFileAsync(siblingReady, siblingFactory.LastCreated!); + + using (JsonDocument ready = JsonDocument.Parse( + await File.ReadAllTextAsync(targetReady))) + { + string[] observed = ready.RootElement + .GetProperty("arguments") + .EnumerateArray() + .Select(value => value.GetString()!) + .ToArray(); + Assert.Equal(exactArguments, observed); + Assert.Equal( + "fixture-input\n".Length, + ready.RootElement.GetProperty("stdinLength").GetInt32()); + Assert.Equal( + 1, + ready.RootElement.GetProperty("stdinLineCount").GetInt32()); + } + + target.Stop(TimeSpan.FromSeconds(10)); + + Assert.Equal(0, target.ExitCode); + Assert.True(File.Exists(targetBreak), + "the target fixture did not observe CTRL_BREAK"); + Assert.Equal(0, targetFactory.LastCreated!.KillCallCount); + Assert.False(siblingFactory.LastCreated!.HasExited); + Assert.False(File.Exists(siblingBreak), + "CTRL_BREAK spilled into the sibling process group"); + + sibling.Stop(TimeSpan.FromSeconds(10)); + Assert.Equal(0, sibling.ExitCode); + Assert.True(File.Exists(siblingBreak)); + Assert.Equal(0, siblingFactory.LastCreated!.KillCallCount); + } + finally + { + targetFactory.LastCreated?.ForceCleanup(); + siblingFactory.LastCreated?.ForceCleanup(); + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + + [Fact] + public async Task WindowsConsolelessParentStillTargetsDistinctChildProcessGroups() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + "acdream-la11-consoleless-parent", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string resultPath = Path.Combine(root, "result.json"); + var startInfo = new ProcessStartInfo + { + FileName = GetConsolelessParentFixturePath(), + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(resultPath); + startInfo.ArgumentList.Add(FindDotnetExecutable()); + startInfo.ArgumentList.Add(GetConsoleFixturePath()); + + try + { + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException( + "The consoleless supervisor fixture did not start."); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(40)); + await process.WaitForExitAsync(timeout.Token); + + Assert.True(File.Exists(resultPath), + "the consoleless supervisor fixture did not write its result"); + using JsonDocument result = JsonDocument.Parse( + await File.ReadAllTextAsync(resultPath)); + Assert.Equal(0, process.ExitCode); + Assert.False(result.RootElement.GetProperty("parentHadConsoleBefore").GetBoolean()); + Assert.False(result.RootElement.GetProperty("parentHadConsoleAfterStarts").GetBoolean()); + Assert.Equal(0, result.RootElement.GetProperty("targetExitCode").GetInt32()); + Assert.Equal(0, result.RootElement.GetProperty("siblingExitCode").GetInt32()); + Assert.True(result.RootElement.GetProperty("targetBreakObserved").GetBoolean()); + Assert.True(result.RootElement.GetProperty("siblingBreakObserved").GetBoolean()); + Assert.True(result.RootElement.GetProperty("siblingUnaffected").GetBoolean()); + } + finally + { + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + [Fact] public void StartKillsAndDisposesTheChildWhenFeedingStdinThrowsAfterTheProcessHasStarted() { @@ -371,6 +546,22 @@ public sealed class LauncherProcessSupervisorTests private static LauncherProcessSpec Spec() => new("fake-host", ["--session-config", "session.json"]); + private static LauncherProcessSpec ConsoleFixtureSpec( + string ready, + string breakMarker, + string label, + IReadOnlyList exactArguments) => + new( + FindDotnetExecutable(), + [ + GetConsoleFixturePath(), + "wait-for-break", + ready, + breakMarker, + label, + .. exactArguments, + ]); + private static string FindDotnetExecutable() => // PATH-based resolution: .NET Core's Process.Start searches PATH // for a bare filename when UseShellExecute is false, on both @@ -378,6 +569,133 @@ public sealed class LauncherProcessSupervisorTests // because this test is itself running under `dotnet test`. OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + private static string GetConsoleFixturePath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild", + "bin", + configuration, + "net10.0", + "AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.dll"); + } + + private static string GetConsolelessParentFixturePath() + { + string configuration = new DirectoryInfo(AppContext.BaseDirectory) + .Parent?.Name ?? "Release"; + return Path.Combine( + FindRepositoryRoot(), + "tests", + "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent", + "bin", + configuration, + "net10.0", + "AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.exe"); + } + + private static string FindRepositoryRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException("Repository root was not found."); + } + + private static async Task WaitForFileAsync( + string path, + RecordingChildProcess child) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + while (!File.Exists(path)) + { + if (child.HasExited) + { + throw new InvalidOperationException( + $"Console fixture exited early with {child.ExitCode}."); + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException("Console fixture did not become ready."); + } + + await Task.Delay(20); + } + } + + private sealed class RecordingRealChildProcessFactory : ILauncherChildProcessFactory + { + private readonly SystemChildProcessFactory _inner = new(); + + internal RecordingChildProcess? LastCreated { get; private set; } + + public ILauncherChildProcess Create(LauncherProcessSpec spec) + { + LastCreated = new RecordingChildProcess(_inner.Create(spec)); + return LastCreated; + } + } + + private sealed class RecordingChildProcess(ILauncherChildProcess inner) + : ILauncherChildProcess + { + public int KillCallCount { get; private set; } + + public bool HasExited => inner.HasExited; + + public int ExitCode => inner.ExitCode; + + public TextWriter StandardInput => inner.StandardInput; + + public event EventHandler? Exited + { + add => inner.Exited += value; + remove => inner.Exited -= value; + } + + public void Start() => inner.Start(); + + public bool TryRequestGracefulStop() => inner.TryRequestGracefulStop(); + + public bool CloseMainWindow() => inner.CloseMainWindow(); + + public void Kill() + { + KillCallCount++; + inner.Kill(); + } + + public bool WaitForExit(TimeSpan timeout) => inner.WaitForExit(timeout); + + public void ForceCleanup() + { + try + { + if (!HasExited) + { + inner.Kill(); + _ = inner.WaitForExit(TimeSpan.FromSeconds(5)); + } + } + catch + { + } + } + + public void Dispose() => inner.Dispose(); + } + private sealed class FakeChildProcessFactory( bool exitsWithinStopTimeout, bool exitDuringStart = false, diff --git a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs index 3d146ffd..a8442f65 100644 --- a/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Orchestration/LauncherExecutableSetTests.cs @@ -44,6 +44,16 @@ public sealed class LauncherExecutableSetTests : IDisposable Assert.Equal( headless, set.CreateProbeSpec("session.json").ExecutablePath); + Assert.False( + set.CreatePlaySpec(LaunchMode.Gui, "session.json") + .SupportsConsoleGracefulStop); + Assert.False( + set.CreatePlaySpec(LaunchMode.GuiSelect, "session.json") + .SupportsConsoleGracefulStop); + Assert.True( + set.CreatePlaySpec(LaunchMode.Headless, "session.json") + .SupportsConsoleGracefulStop); + Assert.True(set.CreateProbeSpec("session.json").SupportsConsoleGracefulStop); } [Fact] diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs new file mode 100644 index 00000000..ff3bb24c --- /dev/null +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -0,0 +1,182 @@ +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher.Tests; + +public sealed class LauncherStartupOptionsTests +{ + [Fact] + public void ExplicitIsolationRootsAreNormalizedAndNeverResolveCanonicalPaths() + { + string root = Path.Combine(Path.GetTempPath(), "acdream-la11 options", "..", "isolation"); + string config = Path.Combine(root, "config") + Path.DirectorySeparatorChar; + string data = Path.Combine(root, "data", ".", "state"); + string cache = Path.Combine(root, "cache") + Path.DirectorySeparatorChar; + bool defaultResolverCalled = false; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [ + "--config-dir", config, + "--data-dir", data, + "--cache-dir", cache, + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ], + () => + { + defaultResolverCalled = true; + throw new InvalidOperationException("canonical path resolver was touched"); + }); + + Assert.False(defaultResolverCalled); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(config)), + options.Paths.ConfigDirectory); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(data)), + options.Paths.DataDirectory); + Assert.Equal( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(cache)), + options.Paths.CacheDirectory); + Assert.Null(options.Paths.LegacyConfigDirectory); + Assert.Equal( + "http://127.0.0.1:43119/manifest.json", + options.UpdateManifestUri.AbsoluteUri); + Assert.Equal(LauncherStartupMode.Desktop, options.Mode); + } + + [Fact] + public void NoOverridesResolveDefaultsExactlyOnce() + { + var expected = new ApplicationPathSet("config", "data", "cache", "legacy"); + int calls = 0; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [], + () => + { + calls++; + return expected; + }); + + Assert.Same(expected, options.Paths); + Assert.Equal(1, calls); + Assert.Equal(ReleaseManifestClient.ProductionManifestUri, options.UpdateManifestUri); + } + + [Fact] + public void VerifyPublishIsExclusiveAndDoesNotResolvePaths() + { + int calls = 0; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--verify-publish"], + () => + { + calls++; + throw new InvalidOperationException(); + }); + + Assert.Equal(LauncherStartupMode.VerifyPublish, options.Mode); + Assert.Equal(0, calls); + Assert.Throws(() => + LauncherStartupOptions.Parse( + ["--verify-publish", "--cache-dir", Path.GetTempPath()])); + } + + [Theory] + [MemberData(nameof(InvalidArguments))] + public void RejectsInvalidPublicArguments(string[] arguments) + { + Assert.Throws(() => + LauncherStartupOptions.Parse( + arguments, + () => new ApplicationPathSet("c", "d", "x", null))); + } + + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://localhost:8123/manifest.json")] + [InlineData("http://[::1]:8123/manifest.json")] + public void AcceptsHttpsAndLoopbackHttpFeeds(string value) + { + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--update-manifest-uri", value], + () => new ApplicationPathSet("c", "d", "x", null)); + + Assert.Equal(new Uri(value), options.UpdateManifestUri); + } + + [Fact] + public void SelfUpdatePrefixesRetainOnlyTheValidatedPublicSuffix() + { + string root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11-self")); + string[] suffix = + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "http://localhost:8123/manifest.json", + ]; + string[] helper = + [ + LauncherSelfUpdateBootstrap.HelperArgument, + "123", + root, + "0123456789abcdef0123456789abcdef", + .. suffix, + ]; + string[] confirmation = + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + "0123456789abcdef0123456789abcdef", + .. suffix, + ]; + + LauncherStartupOptions helperOptions = LauncherStartupOptions.Parse(helper); + LauncherStartupOptions confirmationOptions = + LauncherStartupOptions.Parse(confirmation); + + Assert.Equal(LauncherStartupMode.SelfUpdateHelper, helperOptions.Mode); + Assert.Equal( + LauncherStartupMode.SelfUpdateConfirmation, + confirmationOptions.Mode); + Assert.Equal(suffix, helperOptions.PublicArguments); + Assert.Equal(suffix, confirmationOptions.PublicArguments); + Assert.Equal(helperOptions.Paths, confirmationOptions.Paths); + Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri); + } + + public static TheoryData InvalidArguments() + { + string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11")); + var data = new TheoryData(); + data.Add(["--unknown", "value"]); + data.Add(["--config-dir"]); + data.Add(["--config-dir", "relative"]); + data.Add(["--config-dir", absolute]); + data.Add( + [ + "--config-dir", absolute, + "--data-dir", absolute, + ]); + data.Add( + [ + "--config-dir", absolute, + "--data-dir", absolute, + "--cache-dir", absolute, + "--cache-dir", absolute, + ]); + data.Add( + ["--update-manifest-uri", "http://updates.example.test/manifest.json"]); + data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]); + data.Add( + ["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]); + data.Add(["--update-manifest-uri", "not-a-uri"]); + data.Add( + [ + "--update-manifest-uri", "https://example.test/a", + "--update-manifest-uri", "https://example.test/b", + ]); + return data; + } +} diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 new file mode 100644 index 00000000..98d129eb --- /dev/null +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -0,0 +1,333 @@ +<# +.SYNOPSIS + Creates deterministic isolated Campaign LA A/B update feeds. + +.DESCRIPTION + Packages caller-supplied published client and launcher roots for win-x64 + and linux-x64, adds a deterministic release marker, calculates the exact + LA10 SHA-256/size manifest fields, and emits a loopback-only static server + plus a local A/B selector. It never downloads, connects, edits a payload + source, or writes outside -OutputDirectory. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB, + [string]$VersionA = '1.0.1-la11.a', + [string]$VersionB = '1.0.1-la11.b', + [string]$MinimumLauncherVersion = '1.0.0', + [int]$Port = 43119, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.' +} +if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute.' +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) +if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' } +$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' +if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or + $MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) { + throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.' +} +$parsedVersionA = [semver]$VersionA +$parsedVersionB = [semver]$VersionB +$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion +if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) { + throw 'VersionB must be newer than VersionA.' +} +if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) { + throw 'MinimumLauncherVersion must not be newer than VersionA.' +} + +$sources = [ordered]@{ + 'A-client-win-x64' = $ClientWinX64DirectoryA + 'A-launcher-win-x64' = $LauncherWinX64DirectoryA + 'A-client-linux-x64' = $ClientLinuxX64DirectoryA + 'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA + 'B-client-win-x64' = $ClientWinX64DirectoryB + 'B-launcher-win-x64' = $LauncherWinX64DirectoryB + 'B-client-linux-x64' = $ClientLinuxX64DirectoryB + 'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB +} +foreach ($key in @($sources.Keys)) { + $source = [string]$sources[$key] + if (-not [IO.Path]::IsPathFullyQualified($source)) { + throw "Payload source '$key' must be absolute." + } + $source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source)) + $sources[$key] = $source + if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) { + throw "Payload source '$key' does not exist: $source" + } +} + +function Require-PayloadFile([string]$Key, [string]$Name) { + if ($DryRun) { return } + if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) { + throw "Payload source '$Key' is missing root file '$Name'." + } +} +foreach ($release in @('A', 'B')) { + Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' + Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' + Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' + Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' +} + +if (Test-Path -LiteralPath $OutputDirectory) { + if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) { + throw '-OutputDirectory must not already contain files.' + } +} +else { $null = New-Item -ItemType Directory -Path $OutputDirectory } + +if ($DryRun) { + $plan = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture-plan' + outputDirectory = $OutputDirectory + versions = @($VersionA, $VersionB) + minimumLauncherVersion = $MinimumLauncherVersion + port = $Port + sources = $sources + writesOutsideOutputDirectory = $false + externalNetwork = $false + } + $plan | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM + Write-Host "Campaign LA update fixture dry run: $OutputDirectory" + return +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + +function New-DeterministicZip( + [string]$SourceDirectory, + [string]$Destination, + [string]$ReleaseLabel, + [string]$PayloadKind, + [string]$Rid) { + $destinationDirectory = Split-Path -Parent $Destination + $null = New-Item -ItemType Directory -Force -Path $destinationDirectory + $stream = [IO.FileStream]::new( + $Destination, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Create, + $true, + [Text.Encoding]::UTF8) + try { + $files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse | + Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') }) + foreach ($file in $files) { + if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Payload contains a reparse point: $($file.FullName)" + } + $relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/') + if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or + [IO.Path]::IsPathRooted($relative)) { + throw "Payload path escaped its root: $relative" + } + $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = $fixedTimestamp + $executable = $relative -ceq 'AcDream.App' -or + $relative -ceq 'acdream-headless' -or + $relative -ceq 'acdream-launcher' -or + $relative.EndsWith('.sh', [StringComparison]::Ordinal) + $mode = if ($executable) { 0x81ED } else { 0x81A4 } + $entry.ExternalAttributes = $mode -shl 16 + $input = [IO.File]::OpenRead($file.FullName) + $output = $entry.Open() + try { $input.CopyTo($output) } + finally { $output.Dispose(); $input.Dispose() } + } + $marker = $archive.CreateEntry( + 'campaign-la-fixture-release.txt', + [IO.Compression.CompressionLevel]::Optimal) + $marker.LastWriteTime = $fixedTimestamp + $marker.ExternalAttributes = 0x81A4 -shl 16 + $writer = [IO.StreamWriter]::new( + $marker.Open(), + [Text.UTF8Encoding]::new($false)) + try { + $writer.NewLine = "`n" + $writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n") + } + finally { $writer.Dispose() } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } +} + +function Get-Artifact([string]$Path, [string]$Url) { + $item = Get-Item -LiteralPath $Path + return [ordered]@{ + url = $Url + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + size = $item.Length + } +} + +$releaseDefinitions = @( + [pscustomobject]@{ Label = 'A'; Version = $VersionA }, + [pscustomobject]@{ Label = 'B'; Version = $VersionB } +) +foreach ($release in $releaseDefinitions) { + $releaseRoot = Join-Path $OutputDirectory $release.Label + foreach ($rid in @('win-x64', 'linux-x64')) { + New-DeterministicZip ` + $sources["$($release.Label)-client-$rid"] ` + (Join-Path $releaseRoot "client-$rid.zip") ` + $release.Label 'client' $rid + New-DeterministicZip ` + $sources["$($release.Label)-launcher-$rid"] ` + (Join-Path $releaseRoot "launcher-$rid.zip") ` + $release.Label 'launcher' $rid + } + $baseUri = "http://127.0.0.1:$Port/$($release.Label)" + $manifest = [ordered]@{ + schemaVersion = 1 + version = $release.Version + minimumLauncherVersion = $MinimumLauncherVersion + clients = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-win-x64.zip') ` + "$baseUri/client-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-linux-x64.zip') ` + "$baseUri/client-linux-x64.zip" + } + launchers = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-win-x64.zip') ` + "$baseUri/launcher-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-linux-x64.zip') ` + "$baseUri/launcher-linux-x64.zip" + } + } + $manifest | ConvertTo-Json -Depth 8 -Compress | + Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM +} +Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') ` + -Value 'A' -Encoding ascii -NoNewline + +$server = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][int]$Port +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$prefix = "http://127.0.0.1:$Port/" +$listener = [Net.HttpListener]::new() +$listener.Prefixes.Add($prefix) +$listener.Start() +Write-Host "Campaign LA fixture listening on $prefix" +try { + while ($listener.IsListening) { + $context = $listener.GetContext() + try { + if ($context.Request.HttpMethod -cne 'GET') { + $context.Response.StatusCode = 405 + continue + } + $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + if ($relative -ceq 'manifest.json') { + $active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim() + if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' } + $relative = "$active/manifest.json" + } + if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) { + $context.Response.StatusCode = 404 + continue + } + $path = [IO.Path]::GetFullPath((Join-Path $Root $relative)) + if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or + -not (Test-Path -LiteralPath $path -PathType Leaf)) { + $context.Response.StatusCode = 404 + continue + } + $context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) { + 'application/json' + } else { 'application/zip' } + $context.Response.StatusCode = 200 + $context.Response.Headers['Cache-Control'] = 'no-store' + $context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length + $input = [IO.File]::OpenRead($path) + try { $input.CopyTo($context.Response.OutputStream) } + finally { $input.Dispose() } + } + catch { + $context.Response.StatusCode = 500 + Write-Error $_ + } + finally { $context.Response.Close() } + } +} +finally { $listener.Close() } +'@ +$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM + +$selector = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release, + [string]$Root = $PSScriptRoot +) +Set-StrictMode -Version Latest +$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt' +Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline +Write-Host "Campaign LA fixture active release: $Release" +'@ +$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM + +$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.Name -ne 'fixture-report.json' } | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture' + versions = [ordered]@{ A = $VersionA; B = $VersionB } + minimumLauncherVersion = $MinimumLauncherVersion + manifestUri = "http://127.0.0.1:$Port/manifest.json" + loopbackOnly = $true + initialRelease = 'A' + sourceDirectories = $sources + artifacts = $inventory +} +$report | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM +Write-Host "Campaign LA update fixture: $OutputDirectory" diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 new file mode 100644 index 00000000..0a676df1 --- /dev/null +++ b/tools/run-campaign-la-preflight.ps1 @@ -0,0 +1,394 @@ +<# +.SYNOPSIS + Campaign LA11 display-free, connection-free automated preflight. + +.DESCRIPTION + Runs the exact Release and portability ladder used before the launcher + user gate. It never starts App/Headless in connected mode, never opens a + window, never reads credentials, and never bakes retail DATs. All logs and + publishes are contained beneath one logs/campaign-la-gate- + directory. Use -DryRun to emit the complete command matrix without + executing it. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [string]$OutputDirectory, + [switch]$DryRun, + [switch]$IncludeInstalledDat, + [string]$InstalledDatDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA preflight requires PowerShell 7 or newer.' +} + +$Repository = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($Repository)) +if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) { + throw "Repository does not contain AcDream.slnx: $Repository" +} +if ($IncludeInstalledDat) { + if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or + -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) { + throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.' + } + $InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($InstalledDatDirectory)) + foreach ($file in @( + 'client_portal.dat', + 'client_cell_1.dat', + 'client_highres.dat', + 'client_local_English.dat')) { + if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) { + throw "Installed DAT directory is missing $file." + } + } +} + +$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp" +} +elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + $OutputDirectory = Join-Path $Repository $OutputDirectory +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) +$logsDirectory = Join-Path $OutputDirectory 'commands' +$publishDirectory = Join-Path $OutputDirectory 'publish' +$null = New-Item -ItemType Directory -Force -Path $logsDirectory + +$commandResults = [Collections.Generic.List[object]]::new() +$failures = [Collections.Generic.List[string]]::new() +$startedUtc = [DateTime]::UtcNow + +function Protect-Text([string]$Text) { + if ($null -eq $Text) { return '' } + $protected = $Text + foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not [string]::IsNullOrEmpty($value)) { + $protected = $protected.Replace($value, '', [StringComparison]::Ordinal) + } + } + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(--password|-password)(\s+|=)([^\s"'']+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', + '$1$2') + return $protected +} + +function Format-Command([string]$FilePath, [string[]]$Arguments) { + $parts = [Collections.Generic.List[string]]::new() + $parts.Add($FilePath) + foreach ($argument in $Arguments) { + if ($argument -match '[\s"]') { + $parts.Add('"' + $argument.Replace('"', '\"') + '"') + } + else { $parts.Add($argument) } + } + return $parts -join ' ' +} + +function Add-PlannedCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = 'planned' + startedUtc = $null + durationSeconds = 0 + exitCode = $null + stdout = $null + stderr = $null + environmentKeys = @($Environment.Keys | Sort-Object) + }) +} + +function Invoke-GateCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + if ($DryRun) { + Add-PlannedCommand $Name $FilePath $Arguments $Environment + return + } + + $safeName = $Name -replace '[^A-Za-z0-9_.-]', '-' + $stdoutRelative = "commands/$safeName.out.log" + $stderrRelative = "commands/$safeName.err.log" + $stdoutPath = Join-Path $OutputDirectory $stdoutRelative + $stderrPath = Join-Path $OutputDirectory $stderrRelative + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 74 + try { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.WorkingDirectory = $Repository + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + foreach ($entry in $Environment.GetEnumerator()) { + $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "Could not start $FilePath." } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + [IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout)) + [IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr)) + } + catch { + [IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String))) + } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode + stdout = $stdoutRelative + stderr = $stderrRelative + environmentKeys = @($Environment.Keys | Sort-Object) + }) + } + if ($exitCode -ne 0) { + throw "Preflight command '$Name' failed with exit code $exitCode." + } +} + +function Add-InternalCheck([string]$Name, [scriptblock]$Action) { + if ($DryRun) { + $commandResults.Add([ordered]@{ + name = $Name; command = ''; status = 'planned' + startedUtc = $null; durationSeconds = 0; exitCode = $null + stdout = $null; stderr = $null; environmentKeys = @() + }) + return + } + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 0 + try { & $Action } + catch { $exitCode = 1; throw } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name; command = '' + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @() + }) + } +} + +function Invoke-DotNet([string]$Name, [string[]]$Arguments) { + Invoke-GateCommand $Name 'dotnet' $Arguments +} + +$portableBuildProjects = @( + 'src/AcDream.Platform/AcDream.Platform.csproj', + 'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj', + 'src/AcDream.Bake/AcDream.Bake.csproj', + 'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj', + 'src/AcDream.Core/AcDream.Core.csproj', + 'src/AcDream.Core.Net/AcDream.Core.Net.csproj', + 'src/AcDream.Content/AcDream.Content.csproj', + 'src/AcDream.Runtime/AcDream.Runtime.csproj', + 'src/AcDream.Headless/AcDream.Headless.csproj' +) +$portableTestProjects = @( + 'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj', + 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + 'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj', + 'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj', + 'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj', + 'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj', + 'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj' +) + +try { + Invoke-DotNet 'release-build' @( + 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1') + Invoke-DotNet 'release-tests-serial' @( + 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1', + '--', 'RunConfiguration.MaxCpuCount=1') + Invoke-DotNet 'focused-launcher-updater-core' @( + 'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~Updates') + Invoke-DotNet 'focused-launcher-updater-ui' @( + 'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests') + + foreach ($project in $portableBuildProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-build-$leaf" @( + 'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1') + } + foreach ($project in $portableTestProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-test-$leaf" @( + 'test', $project, '-c', 'Release', '--no-build', '--nologo', + '--', 'RunConfiguration.MaxCpuCount=1') + } + + foreach ($rid in @('win-x64', 'linux-x64')) { + $destination = Join-Path $publishDirectory $rid + Invoke-DotNet "publish-launcher-$rid" @( + 'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj', + '-c', 'Release', '-r', $rid, '--self-contained', 'true', + '-p:PublishSingleFile=true', '-o', $destination, '--nologo') + Add-InternalCheck "publish-contract-$rid" { + $suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' } + foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) { + if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) { + throw "$rid publish is missing $name." + } + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) { + throw "$rid launcher publish is not single-file." + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) { + throw "$rid bake publish is not single-file." + } + if (-not $IsWindows -and $rid -eq 'linux-x64') { + $mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher')) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'linux-x64 launcher is not executable.' + } + } + } + } + + $nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' } + $nativeSuffix = if ($IsWindows) { '.exe' } else { '' } + $nativeRoot = Join-Path $publishDirectory $nativeRid + $bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' } + $bogusEnvironment = @{ + DOTNET_ROOT = $bogusRoot + DOTNET_ROOT_X64 = $bogusRoot + DOTNET_MULTILEVEL_LOOKUP = '0' + } + Invoke-GateCommand 'native-launcher-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-launcher$nativeSuffix") ` + @('--verify-publish') $bogusEnvironment + Invoke-GateCommand 'native-bake-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-bake$nativeSuffix") ` + @('--help') $bogusEnvironment + + if ($IncludeInstalledDat) { + $datEnvironment = @{ + ACDREAM_DAT_DIR = $InstalledDatDirectory + ACDREAM_PROBE_LIVE_MOUNT = '1' + } + $datResults = Join-Path $OutputDirectory 'installed-dat-results' + Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests', + '--results-directory', $datResults, + '--logger', 'trx;LogFileName=character-management.trx') $datEnvironment + Add-InternalCheck 'installed-dat-character-management-require-pass' { + $trx = Join-Path $datResults 'character-management.trx' + if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) { + throw 'CharacterManagementLiveDatTests did not produce a TRX result.' + } + [xml]$result = Get-Content -LiteralPath $trx -Raw + $outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome }) + if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or + @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) { + throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')." + } + } + Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @( + 'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment + Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment + } +} +catch { + $failures.Add((Protect-Text ($_ | Out-String)).Trim()) +} +finally { + $finishedUtc = [DateTime]::UtcNow + $head = (& git -C $Repository rev-parse HEAD).Trim() + $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all) + $artifacts = @() + if (-not $DryRun) { + $artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) + } + $failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' }) + $report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-automated-preflight' + dryRun = [bool]$DryRun + success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0) + repository = $Repository + head = $head + dirty = ($dirtyLines.Count -gt 0) + dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ }) + platform = [ordered]@{ + os = [Runtime.InteropServices.RuntimeInformation]::OSDescription + architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier + framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription + powershell = $PSVersionTable.PSVersion.ToString() + } + startedUtc = $startedUtc.ToString('O') + finishedUtc = $finishedUtc.ToString('O') + durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3) + installedDatIncluded = [bool]$IncludeInstalledDat + commands = @($commandResults) + failures = @($failures) + redaction = [ordered]@{ + applied = $true + environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') + credentialArgumentsAllowed = $false + } + artifacts = $artifacts + } + $reportPath = Join-Path $OutputDirectory 'report.json' + $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM + Write-Host "Campaign LA preflight report: $reportPath" + if (-not $report.success) { exit 1 } +} diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 new file mode 100644 index 00000000..834ff031 --- /dev/null +++ b/tools/test-campaign-la-session-status.ps1 @@ -0,0 +1,346 @@ +<# +.SYNOPSIS + Strict Campaign LA v1 session-status and terminal-process validator. + +.DESCRIPTION + Validates exact JSONL property sets and property order, lifecycle order for + probe/guiSelect/gui/headless, terminal semantics, plugin expectations, + credential redaction, and absence of launcher child-process leaks. The + report contains hashes and event names only; it does not copy account, + character, command, plugin-error, or other payload text. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$StatusFile, + [Parameter(Mandatory = $true)] + [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, + [string]$ExpectedSessionId, + [string[]]$ExpectedPlugin = @(), + [switch]$ExpectNoEnteredWorld, + [switch]$AllowPluginFailure, + [switch]$AllowLoginCommandFailure, + [switch]$AllowLauncherChildren, + [string[]]$ForbiddenEnvironmentVariable = @( + 'ACDREAM_TEST_PASS', + 'ACDREAM_LA_GATE_SECRET'), + [string]$ReportPath, + [int]$ProcessExitWaitSeconds = 5 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA status validation requires PowerShell 7 or newer.' +} +if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { + throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' +} +if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { + $StatusFile = [IO.Path]::GetFullPath($StatusFile) +} +if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { + throw "Status file does not exist: $StatusFile" +} +if ([string]::IsNullOrWhiteSpace($ReportPath)) { + $ReportPath = "$StatusFile.validation.json" +} +elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { + $ReportPath = [IO.Path]::GetFullPath($ReportPath) +} + +$exactFields = @{ + started = @('v', 'e', 't', 'sessionId') + connected = @('v', 'e', 't', 'sessionId') + characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters') + enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName') + pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin') + pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error') + loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error') + disconnected = @('v', 'e', 't', 'sessionId', 'reason') + exited = @('v', 'e', 't', 'sessionId', 'code', 'reason') +} +$failures = [Collections.Generic.List[string]]::new() +$eventNames = [Collections.Generic.List[string]]::new() +$loadedPlugins = [Collections.Generic.List[string]]::new() +$sessionId = $null +$previousTimestamp = [DateTimeOffset]::MinValue +$terminalSeen = $false + +function Get-Properties([Text.Json.JsonElement]$Element) { + $properties = [Collections.Generic.List[object]]::new() + foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) } + return @($properties) +} + +function Assert-String( + [Text.Json.JsonElement]$Root, + [string]$Name, + [bool]$AllowEmpty = $false) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) { + throw "field '$Name' is not a string" + } + $text = $value.GetString() + if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) { + throw "field '$Name' is empty" + } + return $text +} + +function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetInt32() +} + +function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetUInt32() +} + +$lines = @(Get-Content -LiteralPath $StatusFile) +if ($lines.Count -eq 0) { $failures.Add('status stream is empty') } +for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { + $lineNumber = $lineIndex + 1 + $line = $lines[$lineIndex] + if ([string]::IsNullOrWhiteSpace($line)) { + $failures.Add("line $lineNumber is empty") + continue + } + foreach ($variable in $ForbiddenEnvironmentVariable) { + $secret = [Environment]::GetEnvironmentVariable($variable) + if (-not [string]::IsNullOrEmpty($secret) -and + $line.Contains($secret, [StringComparison]::Ordinal)) { + $failures.Add("line $lineNumber contains the value of forbidden environment variable $variable") + } + } + if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') { + $failures.Add("line $lineNumber contains a credential-like JSON field") + } + + $document = $null + try { + $document = [Text.Json.JsonDocument]::Parse($line) + $root = $document.RootElement + if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'root is not an object' + } + $properties = @(Get-Properties $root) + $names = @($properties | ForEach-Object { $_.Name }) + if (@($names | Sort-Object -Unique).Count -ne $names.Count) { + throw 'object contains duplicate fields' + } + $eventName = Assert-String $root 'e' + if (-not $exactFields.ContainsKey($eventName)) { + throw "event '$eventName' is not in the v1 vocabulary" + } + $expected = $exactFields[$eventName] + if ($names.Count -ne $expected.Count -or + [string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) { + throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'" + } + if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' } + $timestampText = Assert-String $root 't' + $timestamp = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $timestampText, + 'O', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) { + throw 'field t is not an exact UTC round-trip timestamp' + } + if ($timestamp -lt $previousTimestamp) { + throw 'timestamp order moved backwards' + } + $previousTimestamp = $timestamp + $lineSessionId = Assert-String $root 'sessionId' + if ($null -eq $sessionId) { $sessionId = $lineSessionId } + if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' } + if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and + $lineSessionId -cne $ExpectedSessionId) { + throw 'sessionId does not match -ExpectedSessionId' + } + if ($terminalSeen) { throw 'an event appears after terminal exited' } + + switch ($eventName) { + 'characterList' { + $null = Assert-String $root 'accountName' $true + $slotCount = Assert-Int32 $root 'slotCount' + if ($slotCount -lt 0) { throw 'slotCount is negative' } + $characters = $root.GetProperty('characters') + if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) { + throw 'characters is not an array' + } + foreach ($character in $characters.EnumerateArray()) { + if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'a character is not an object' + } + $characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name }) + $characterExpected = @('id', 'name', 'secondsGreyedOut') + if ([string]::Join("`n", $characterNames) -cne + [string]::Join("`n", $characterExpected)) { + throw 'a character fields/order is not id,name,secondsGreyedOut' + } + $null = Assert-UInt32 $character 'id' + $null = Assert-String $character 'name' + $null = Assert-UInt32 $character 'secondsGreyedOut' + } + } + 'enteredWorld' { + $null = Assert-UInt32 $root 'characterId' + $null = Assert-String $root 'characterName' + } + 'pluginLoaded' { + $loadedPlugins.Add((Assert-String $root 'plugin')) + } + 'pluginFailed' { + $null = Assert-String $root 'plugin' + $null = Assert-String $root 'error' + if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' } + } + 'loginCommandFailed' { + if ((Assert-Int32 $root 'commandIndex') -lt 0) { + throw 'commandIndex is negative' + } + $null = Assert-String $root 'command' $true + $null = Assert-String $root 'error' + if (-not $AllowLoginCommandFailure) { + throw 'loginCommandFailed is not allowed for this row' + } + } + 'disconnected' { $null = Assert-String $root 'reason' } + 'exited' { + $code = Assert-Int32 $root 'code' + $reason = Assert-String $root 'reason' + if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } + $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } + if ($reason -cne $expectedReason) { + throw "terminal reason is '$reason', expected '$expectedReason'" + } + $terminalSeen = $true + } + } + $eventNames.Add($eventName) + } + catch { + $failures.Add("line ${lineNumber}: $($_.Exception.Message)") + } + finally { if ($null -ne $document) { $document.Dispose() } } +} + +function Require-Count([string]$EventName, [int]$Count) { + $actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count + if ($actual -ne $Count) { + $failures.Add("event '$EventName' count is $actual, expected $Count") + } +} +function First-Index([string]$EventName) { + for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq $EventName) { return $index } + } + return -1 +} + +Require-Count 'started' 1 +Require-Count 'connected' 1 +Require-Count 'characterList' 1 +Require-Count 'disconnected' 1 +Require-Count 'exited' 1 +$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld +Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 }) +if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') { + $failures.Add('started is not the first event') +} +if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') { + $failures.Add('exited is not the final event') +} +$orderedRequired = if (-not $expectEnteredWorld) { + @('started', 'connected', 'characterList', 'disconnected', 'exited') +} else { + @('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited') +} +$last = -1 +foreach ($name in $orderedRequired) { + $next = First-Index $name + if ($next -ge 0 -and $next -le $last) { + $failures.Add("event '$name' is out of lifecycle order") + } + $last = $next +} +$connectedIndex = First-Index 'connected' +foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) { + if ($eventNames.Count -eq 0) { break } + if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and + ($index -le 0 -or $index -ge $connectedIndex)) { + $failures.Add("plugin event at index $index is outside started-to-connected startup") + } +} +foreach ($plugin in $ExpectedPlugin) { + if (-not ($loadedPlugins -ccontains $plugin)) { + $failures.Add("expected plugin '$plugin' did not emit pluginLoaded") + } +} +$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique) +$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique) +if ($loadedPlugins.Count -ne $loadedPluginSet.Count) { + $failures.Add('a plugin emitted pluginLoaded more than once') +} +if ([string]::Join("`n", $loadedPluginSet) -cne + [string]::Join("`n", $expectedPluginSet)) { + $failures.Add( + "loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)") +} +$enteredWorldIndex = First-Index 'enteredWorld' +for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq 'loginCommandFailed' -and + ($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) { + $failures.Add("loginCommandFailed at index $index did not follow enteredWorld") + } +} + +if (-not $AllowLauncherChildren) { + $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) + do { + $children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue) + if ($children.Count -eq 0) { break } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $deadline) + if ($children.Count -gt 0) { + $failures.Add( + "launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')") + } +} + +$reportDirectory = Split-Path -Parent $ReportPath +if (-not [string]::IsNullOrEmpty($reportDirectory)) { + $null = New-Item -ItemType Directory -Force -Path $reportDirectory +} +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-session-status-validation' + success = ($failures.Count -eq 0) + mode = $Mode + enteredWorldExpected = $expectEnteredWorld + statusFile = [IO.Path]::GetFileName($StatusFile) + statusSize = (Get-Item -LiteralPath $StatusFile).Length + statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant() + lineCount = $lines.Count + eventNames = @($eventNames) + loadedPluginCount = $loadedPlugins.Count + terminalObserved = $terminalSeen + launcherChildrenAllowed = [bool]$AllowLauncherChildren + failures = @($failures) + validatedUtc = [DateTime]::UtcNow.ToString('O') +} +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM +Write-Host "Campaign LA status validation report: $ReportPath" +if (-not $report.success) { + $failures | ForEach-Object { Write-Error $_ } + exit 1 +} From 0ac8bf61711352bc3ca3a4462775f2723582abca Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 23:50:23 +0200 Subject: [PATCH 052/138] docs(launcher): close Campaign LA10 --- CLAUDE.md | 8 ++++---- docs/plans/2026-04-11-roadmap.md | 10 +++++----- docs/plans/2026-08-14-launcher-campaign.md | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9cf60ec2..c361d6bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,14 +245,14 @@ NO 3D preview (chargen-only machinery); UI Studio no longer exists (deleted at Campaign V — ignore stale memory/docs claims otherwise); App `Program.cs` has no subcommand dispatch (the `--session-config` flag is additive). -LA0 through LA9 are review-closed. The launcher composer is now +LA0 through LA10 are review-closed. The launcher composer is now compiled into both host test suites, and Launcher.Core runs in the portable Windows/Ubuntu CI closure. The self-contained Avalonia launcher, transactional two-host plugin lifetime, shared login-command route, Runtime-owned retail selection state, authored DAT character screen, and -crash-safe verified installer are integrated; the combined Release gate -passes 13,865 tests / 5 skips. LA10 updater is the final implementation slice; -LA11 closeout follows. +crash-safe verified installer plus atomic cross-platform updater/self-updater +are integrated; the combined Release gate passes 13,972 tests / 5 skips. LA11 +automated and connected/visual closeout is the only remaining slice. **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 7033f339..5f64967f 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -103,15 +103,15 @@ a future campaign). Spec: [`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); plan + ledger: [`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). -LA0 through LA9 are review-closed: the portable path boundary, +LA0 through LA10 are review-closed: the portable path boundary, failure-isolated launch/status contract, BCL-only launcher core, shared composer-to-both-host-loader anti-drift gate, and character wire messages are landed. The self-contained Avalonia launcher, transactional two-host plugin lifetime, shared login-command route, Runtime-owned retail selection state, -authored DAT character screen, and crash-safe verified installer are -integrated. The combined Release gate passes 13,865 tests / 5 skips. LA10 -updater is the final implementation slice; LA11 connected/visual closeout -follows. +authored DAT character screen, crash-safe verified installer, and atomic +cross-platform updater/self-updater are integrated. The combined Release gate +passes 13,972 tests / 5 skips. LA11 automated and connected/visual closeout is +the only remaining slice. **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 17a59c0a..5f6f292d 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -728,5 +728,5 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA7 | **DONE + MERGED 2026-08-14** | LA7a `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4`; LA7b `0e82cbf7`, `1b9e7e41`, `ff406562`, merge `7691cf75` | LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed | Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; `NumErrors` is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green. | | LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. | | LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. | -| LA10 | — | | | | +| LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0–LA10 gate: 13,972+5 skip. | | LA11 | — | | | | From 134edabed29fcf63d53ae68a62973d7aae74009f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 00:02:04 +0200 Subject: [PATCH 053/138] feat(launcher): complete Campaign LA11 pre-gate support --- docs/architecture/acdream-architecture.md | 8 ++ docs/plans/2026-08-14-launcher-campaign.md | 2 +- .../2026-08-14-campaign-la-test-script.md | 18 +++-- .../2026-08-14-launcher-campaign-design.md | 5 +- .../Updates/ReleaseManifestClient.cs | 48 +++++++++++- src/AcDream.Launcher/AcDream.Launcher.csproj | 4 - src/AcDream.Launcher/App.axaml.cs | 21 ++++- .../LauncherStartupOptions.cs | 22 ++++++ .../LauncherUpdateComposition.cs | 23 +++++- src/AcDream.Launcher/Program.cs | 47 ++++++++---- .../Program.cs | 9 ++- .../Updates/LauncherSelfUpdateManagerTests.cs | 28 +++++-- .../Updates/LauncherSelfUpdateProcessTests.cs | 16 +++- .../Updates/ReleaseTransportTests.cs | 20 +++++ .../LauncherStartupOptionsTests.cs | 76 +++++++++++++++++++ .../LauncherUpdateCompositionTests.cs | 40 ++++++++++ tools/new-campaign-la-update-fixture.ps1 | 43 ++++++++++- tools/run-campaign-la-preflight.ps1 | 54 +++++++++++-- tools/test-campaign-la-session-status.ps1 | 4 +- 19 files changed, 433 insertions(+), 55 deletions(-) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c909820d..b4b000b9 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -343,6 +343,14 @@ src/ -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell + Startup/Program -> one immutable process-local option graph before + owner construction; config/data/cache require + three absolute normalized roots and one exact + `ApplicationPathSet` reaches profiles, installer, + versions/updater, sessions, cache, orchestration + -> manifest override reaches only update composition, + is never persisted, and permits HTTP only for a + loopback fixture; production remains pinned HTTPS ViewModels/ -> thin MVVM projection over Launcher.Core, including the first-run DAT/bake wizard and nonfatal startup/manual update state, actions, diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 76fd65bc..a07b767a 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | — | | | | | LA9 | — | | | | | LA10 | — | | | | -| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing, Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. Launcher startup composition awaits the LA10 review-fix rebase; no connected row has run and the campaign is not shipped. | +| LA11 | **IMPLEMENTATION CHECKPOINT 2026-08-14 — USER GATE PENDING** | pending integration | Review and connected/visual acceptance pending | Strict isolated-root/feed parsing and one exact launcher path graph are composed on the reviewed LA10 updater base; Windows targeted CTRL_BREAK fixtures, deterministic A/B loopback fixture, automated preflight/status validators, and the exact Windows + Ubuntu/WSL operator script are implemented. No connected row has run and the campaign is not shipped. | diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md index 664a569e..25109d73 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -72,15 +72,18 @@ The expected matrix is: | Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min | | Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min | | Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min | -| Windows | canonical portable project build/test closure from `headless-portability.yml` | every project exits 0 | 10–25 min | +| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min | | Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min | | Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min | | Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min | `report.json` records the tested HEAD/dirty state, OS/RID, exact commands, durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight -plans 26 commands. It never launches App or Headless in connected mode and -never reads a credential. +plans 30 rows. It never launches App or Headless in connected mode and +never reads a credential. Every child starts with all inherited `ACDREAM_*` +variables removed, so a developer shell cannot accidentally enable live, +installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional +row below adds the two named DAT variables back for its three exact tests. ### Optional installed-DAT read-only row @@ -100,7 +103,7 @@ The mandatory installed-DAT result is `ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX and fails if the test skipped or did anything other than pass. The action-map and portal-asset probes are additional coverage, never substitutes. Expected -matrix size: 30 rows. +matrix size: 34 rows. On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository path, and a Linux output path. Do not treat a Windows-hosted run over @@ -167,9 +170,12 @@ pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1 ``` The helper rejects nonempty output, invalid or non-monotonic versions, missing -root executables, and nonabsolute inputs. It writes fixed-timestamp sorted ZIPs, +root executables (including the co-deployed Bake CLI), and nonabsolute inputs. +It writes fixed-timestamp sorted ZIPs, the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only -server, and an A/B selector. It does not download or mutate payload sources. +server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B +selector. Both generated helpers reject a `-Root` other than their own fixture +directory. The generator does not download or mutate payload sources. Start the Windows loopback server without a shell or visible helper window: diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md index dbd662b3..0224a7f7 100644 --- a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -333,7 +333,10 @@ the process supervisor executes. (load, capability flag, teardown). - **Connected gates (user-driven):** execute the exact serial matrix in `docs/research/2026-08-14-campaign-la-test-script.md` only after its - connection-free automated preflight passes. Cover every launch mode against local ACE + connection-free automated preflight passes. The launcher uses one immutable + process-local config/data/cache path set for the whole matrix; the local feed + URI reaches only updater composition and is never persisted. Cover every + launch mode against local ACE (gui / guiSelect / headless), the character probe (fresh account → refresh → roster appears, and repeated probes leaving no stale ACE session), clean-profile first-run wizard end-to-end, staged-manifest diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index a31625c1..7736fee5 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -11,8 +11,9 @@ public interface IReleaseManifestClient /// /// Strict, bounded reader for the pinned GitHub Releases manifest. Production -/// construction is HTTPS-only. The loopback HTTP allowance is available only -/// through an internal fixture factory and is never inferred from a URI. +/// construction is pinned and HTTPS-only. The explicitly named process-local +/// feed factory independently revalidates its URI and can admit HTTP only for +/// the loopback operator fixture; it cannot change the production constructor. /// Redirects are followed manually so every hop is checked before any bytes /// cross that hop. /// @@ -74,6 +75,49 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable CreateRedirectDisabledHandler(), timeout); + /// + /// Creates the explicit process-local feed seam used by the Campaign LA + /// isolated operator fixture. HTTPS stays HTTPS-only. HTTP is admitted + /// only for a loopback manifest, and never by the pinned production + /// constructor. Credential-bearing or mutable URI suffixes are rejected. + /// + public static ReleaseManifestClient CreateLocalUpdateFeedOverride( + Uri manifestUri, + TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(manifestUri); + if (!string.IsNullOrEmpty(manifestUri.UserInfo) + || !string.IsNullOrEmpty(manifestUri.Query) + || !string.IsNullOrEmpty(manifestUri.Fragment)) + { + throw new LauncherUpdateException( + "A process-local manifest URI cannot contain user information, " + + "a query, or a fragment."); + } + + bool allowLoopbackHttp = string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttp, + StringComparison.Ordinal) + && manifestUri.IsLoopback; + if (!string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttps, + StringComparison.Ordinal) + && !allowLoopbackHttp) + { + throw new LauncherUpdateException( + "A process-local manifest URI must use HTTPS " + + "(loopback HTTP is fixture-only)."); + } + + return new ReleaseManifestClient( + manifestUri, + allowLoopbackHttp, + CreateRedirectDisabledHandler(), + timeout); + } + internal static ReleaseManifestClient CreateForTransportTest( Uri manifestUri, bool allowLoopbackHttp, diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 1b02f993..350260b2 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -28,10 +28,6 @@ - - - - diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index c42f0de0..b348cf07 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -14,17 +14,33 @@ namespace AcDream.Launcher; public sealed partial class App : Application { + private readonly LauncherStartupOptions? _startupOptions; private LauncherOrchestrator? _orchestrator; private LauncherWindowViewModel? _viewModel; private LauncherUpdateComposition? _updateComposition; + public App() + { + } + + internal App(LauncherStartupOptions startupOptions) + { + _startupOptions = startupOptions + ?? throw new ArgumentNullException(nameof(startupOptions)); + } + + internal LauncherStartupOptions StartupOptions => _startupOptions + ?? throw new InvalidOperationException( + "Launcher startup options were not supplied by the composition root."); + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherStartupOptions startupOptions = StartupOptions; + ApplicationPathSet paths = startupOptions.Paths; LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); string rid = LauncherRuntimeIdentity.DetectRid(); string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; @@ -57,7 +73,8 @@ public sealed partial class App : Application GetLauncherVersion(), AppContext.BaseDirectory, () => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive) - == true); + == true, + updateManifestUri: startupOptions.UpdateManifestUri); _updateComposition = updates; _orchestrator = new LauncherOrchestrator( diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs index 1f1c9164..43b3429e 100644 --- a/src/AcDream.Launcher/LauncherStartupOptions.cs +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -9,6 +9,7 @@ internal enum LauncherStartupMode VerifyPublish, SelfUpdateHelper, SelfUpdateConfirmation, + SelfUpdateDeferred, } /// @@ -18,6 +19,12 @@ internal enum LauncherStartupMode /// internal sealed class LauncherStartupOptions { + // This prefix is consumed only after LauncherSelfUpdateBootstrap has + // already decided to continue after a recovered rollback. Keep it local + // so the process-level bootstrap can remain internal to Launcher.Core. + private const string DeferredSelfUpdateArgument = + "--acdream-self-update-deferred-v1"; + private readonly IReadOnlyList _publicArguments; private LauncherStartupOptions( @@ -138,6 +145,13 @@ internal sealed class LauncherStartupOptions "The update manifest URI cannot contain user information."); } + if (!string.IsNullOrEmpty(parsed.Query) + || !string.IsNullOrEmpty(parsed.Fragment)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain a query or fragment."); + } + updateManifestUri = parsed; break; default: @@ -199,6 +213,14 @@ internal sealed class LauncherStartupOptions arguments.Count >= 2 ? 2 : arguments.Count); } + if (string.Equals( + arguments[0], + DeferredSelfUpdateArgument, + StringComparison.Ordinal)) + { + return (LauncherStartupMode.SelfUpdateDeferred, 1); + } + return (LauncherStartupMode.Desktop, 0); } diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs index 3b395042..22af717f 100644 --- a/src/AcDream.Launcher/LauncherUpdateComposition.cs +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -22,12 +22,14 @@ internal sealed class LauncherUpdateComposition : IDisposable ClientVersionStore versions, LauncherExecutableSet executables, ILauncherUpdater updater, + Uri updateManifestUri, HttpClient? artifactClient, ReleaseManifestClient? manifestClient) { Versions = versions; Executables = executables; Updater = updater; + UpdateManifestUri = updateManifestUri; _artifactClient = artifactClient; _manifestClient = manifestClient; } @@ -38,17 +40,22 @@ internal sealed class LauncherUpdateComposition : IDisposable public ILauncherUpdater Updater { get; } + internal Uri UpdateManifestUri { get; } + public static LauncherUpdateComposition Create( ApplicationPathSet paths, string rid, LauncherVersion launcherVersion, string launcherTargetDirectory, Func hasRunningSessions, - Func? initialize = null) + Func? initialize = null, + Uri? updateManifestUri = null) { ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(launcherVersion); ArgumentNullException.ThrowIfNull(hasRunningSessions); + Uri manifestUri = updateManifestUri + ?? ReleaseManifestClient.ProductionManifestUri; var versions = new ClientVersionStore(paths); HttpClient? artifactClient = null; ReleaseManifestClient? manifestClient = null; @@ -69,7 +76,7 @@ internal sealed class LauncherUpdateComposition : IDisposable Timeout = TimeSpan.FromSeconds(15), }; artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); - manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15)); + manifestClient = CreateManifestClient(manifestUri); var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient); var updater = new LauncherUpdater( manifestClient, @@ -84,6 +91,7 @@ internal sealed class LauncherUpdateComposition : IDisposable versions, LauncherExecutableSet.FromCurrentVersionStore(versions), updater, + manifestUri, artifactClient, manifestClient); } @@ -106,6 +114,7 @@ internal sealed class LauncherUpdateComposition : IDisposable versions, LauncherExecutableSet.Unavailable(status), new UnavailableLauncherUpdater(status, resolution), + manifestUri, artifactClient: null, manifestClient: null); } @@ -117,6 +126,16 @@ internal sealed class LauncherUpdateComposition : IDisposable _artifactClient?.Dispose(); } + private static ReleaseManifestClient CreateManifestClient(Uri manifestUri) + { + ArgumentNullException.ThrowIfNull(manifestUri); + return manifestUri == ReleaseManifestClient.ProductionManifestUri + ? new ReleaseManifestClient(TimeSpan.FromSeconds(15)) + : ReleaseManifestClient.CreateLocalUpdateFeedOverride( + manifestUri, + TimeSpan.FromSeconds(15)); + } + private static bool IsStorageFailure(Exception exception) => exception is IOException or UnauthorizedAccessException diff --git a/src/AcDream.Launcher/Program.cs b/src/AcDream.Launcher/Program.cs index 4bce034a..bb6b7c1f 100644 --- a/src/AcDream.Launcher/Program.cs +++ b/src/AcDream.Launcher/Program.cs @@ -1,5 +1,4 @@ using AcDream.Launcher.Core.Updates; -using AcDream.Platform; using Avalonia; namespace AcDream.Launcher; @@ -9,19 +8,18 @@ internal static class Program [STAThread] public static int Main(string[] args) { - if (args is ["--verify-publish"]) - { - // A display-free execution probe for the packaged artifact. CI - // runs this with DOTNET_ROOT pointing at a missing directory; a - // framework-dependent publish cannot reach this return statement. - return 0; - } - try { - ApplicationPathSet paths = ApplicationPathSet.Resolve(); + LauncherStartupOptions options = LauncherStartupOptions.Parse(args); + if (options.Mode == LauncherStartupMode.VerifyPublish) + { + // A display-free execution probe for the packaged artifact. + // Parsing above deliberately never resolves user paths. + return 0; + } + using var httpClient = new HttpClient(); - var selfUpdates = new LauncherSelfUpdateManager(paths, httpClient); + var selfUpdates = new LauncherSelfUpdateManager(options.Paths, httpClient); string executable = Environment.ProcessPath ?? throw new InvalidOperationException( "The launcher executable path is unavailable."); @@ -37,8 +35,9 @@ internal static class Program return startup.ExitCode; } - return BuildAvaloniaApp().StartWithClassicDesktopLifetime( - startup.RemainingArguments); + RequireUnchangedPublicArguments(options, startup); + + return BuildAvaloniaApp(options).StartWithClassicDesktopLifetime([]); } catch (Exception ex) { @@ -47,7 +46,25 @@ internal static class Program } } - public static AppBuilder BuildAvaloniaApp() => - AppBuilder.Configure() + internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return AppBuilder.Configure(() => new App(options)) .UsePlatformDetect(); + } + + internal static void RequireUnchangedPublicArguments( + LauncherStartupOptions options, + SelfUpdateStartupResult startup) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(startup); + if (!startup.RemainingArguments.SequenceEqual( + options.PublicArguments, + StringComparer.Ordinal)) + { + throw new InvalidOperationException( + "The self-update bootstrap changed validated launcher arguments."); + } + } } diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index 8e3f45ac..18a98d1e 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -160,18 +160,23 @@ static async Task BootstrapProbeAsync(string[] arguments) static int CanonicalProbe(string[] arguments) { - if (arguments.Length != 1) + if (arguments.Length < 1) { return 2; } + string suffix = arguments.Length == 1 + ? string.Empty + : Environment.NewLine + + string.Join(Environment.NewLine, arguments[1..]); File.WriteAllText( Path.GetFullPath(arguments[0]), Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "|" + Path.GetFullPath( Environment.ProcessPath - ?? throw new InvalidOperationException("Process path is unavailable."))); + ?? throw new InvalidOperationException("Process path is unavailable.")) + + suffix); return 0; } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index e2c9b4b3..40fe89f3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -279,25 +279,43 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing() { using var harness = new Harness(_root); + string[] publicArguments = + [ + "--config-dir", Path.Combine(_root, "config with spaces"), + "--data-dir", Path.Combine(_root, "data & literal"), + "--cache-dir", Path.Combine(_root, "cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync( - ["--literal", "argument with spaces & metacharacters"], + publicArguments, harness.Manager, harness.Target, harness.LauncherPath); Assert.False(ordinary.ShouldExit); - Assert.Equal(["--literal", "argument with spaces & metacharacters"], - ordinary.RemainingArguments); + Assert.Equal(publicArguments, ordinary.RemainingArguments); + + SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.False(deferred.ShouldExit); + Assert.Equal(publicArguments, deferred.RemainingArguments); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( - [LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId], + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + applied.TransactionId, + .. publicArguments, + ], harness.Manager, harness.Target, harness.LauncherPath); Assert.False(confirmation.ShouldExit); - Assert.Empty(confirmation.RemainingArguments); + Assert.Equal(publicArguments, confirmation.RemainingArguments); Assert.False(File.Exists(harness.Manager.PendingPlanPath)); Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); } diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index 1c67efc3..5185fc7d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -34,6 +34,13 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable string ready = Path.Combine(_root, "crash.ready"); string launched = Path.Combine(_root, "replacement.ready"); string helperPidPath = Path.Combine(_root, "helper.pid"); + string[] processLocalSuffix = + [ + "--config-dir", Path.Combine(_root, "isolated config"), + "--data-dir", Path.Combine(_root, "isolated data"), + "--cache-dir", Path.Combine(_root, "isolated cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; Directory.CreateDirectory(_root); string rid = LauncherRuntimeIdentity.DetectRid(); PreparedLauncher prepared = PrepareLauncherClosure(target, rid); @@ -76,7 +83,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable }; using Process canonical = StartProcess( prepared.CanonicalPath, - ["canonical-probe", launched], + ["canonical-probe", launched, .. processLocalSuffix], environment); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); Assert.Equal(0, canonical.ExitCode); @@ -100,7 +107,12 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable SearchOption.TopDirectoryOnly)); Assert.Null(await manager.LoadPendingAsync()); - int replacementPid = ParsePid(await File.ReadAllTextAsync(launched)); + string launchMarker = await File.ReadAllTextAsync(launched); + Assert.EndsWith( + Environment.NewLine + string.Join(Environment.NewLine, processLocalSuffix), + launchMarker, + StringComparison.Ordinal); + int replacementPid = ParsePid(launchMarker); int helperPid = int.Parse( await File.ReadAllTextAsync(helperPidPath), System.Globalization.CultureInfo.InvariantCulture); diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index b3d80e11..0208d60d 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -44,6 +44,26 @@ public sealed class LauncherVersionTests public sealed class ReleaseManifestClientTests { + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://127.0.0.1:43119/manifest.json")] + [InlineData("http://localhost:43119/manifest.json")] + public void LocalUpdateFeedOverrideAcceptsOnlySecureOrLoopbackFeeds(string value) + { + using ReleaseManifestClient source = + ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value)); + } + + [Theory] + [InlineData("http://updates.example.test/manifest.json")] + [InlineData("file:///tmp/manifest.json")] + [InlineData("https://user:secret@updates.example.test/manifest.json")] + [InlineData("https://updates.example.test/manifest.json?token=secret")] + [InlineData("https://updates.example.test/manifest.json#fragment")] + public void LocalUpdateFeedOverrideRejectsRemoteHttpAndCredentialLikeUris(string value) => + Assert.Throws(() => + ReleaseManifestClient.CreateLocalUpdateFeedOverride(new Uri(value))); + [Fact] public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed() { diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs index ff3bb24c..d7f223ce 100644 --- a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -146,6 +146,78 @@ public sealed class LauncherStartupOptionsTests Assert.Equal(helperOptions.UpdateManifestUri, confirmationOptions.UpdateManifestUri); } + [Fact] + public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-la11-deferred")); + string[] suffix = + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", + ]; + + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--acdream-self-update-deferred-v1", .. suffix], + () => throw new InvalidOperationException( + "canonical path resolver was touched")); + + Assert.Equal(LauncherStartupMode.SelfUpdateDeferred, options.Mode); + Assert.Equal(suffix, options.PublicArguments); + Assert.Equal(Path.Combine(root, "config"), options.Paths.ConfigDirectory); + Assert.Equal(Path.Combine(root, "data"), options.Paths.DataDirectory); + Assert.Equal(Path.Combine(root, "cache"), options.Paths.CacheDirectory); + } + + [Fact] + public void AvaloniaCompositionRetainsTheExactParsedOptionsAndPathSet() + { + string root = Path.GetFullPath( + Path.Combine(Path.GetTempPath(), "acdream-la11-app-composition")); + LauncherStartupOptions options = LauncherStartupOptions.Parse( + [ + "--config-dir", Path.Combine(root, "config"), + "--data-dir", Path.Combine(root, "data"), + "--cache-dir", Path.Combine(root, "cache"), + "--update-manifest-uri", "https://updates.example.test/manifest.json", + ], + () => throw new InvalidOperationException( + "canonical path resolver was touched")); + + var app = new App(options); + + Assert.Same(options, app.StartupOptions); + Assert.Same(options.Paths, app.StartupOptions.Paths); + Assert.Equal( + new Uri("https://updates.example.test/manifest.json"), + app.StartupOptions.UpdateManifestUri); + } + + [Fact] + public void CompositionRejectsAnyBootstrapArgumentDrift() + { + var paths = new ApplicationPathSet("config", "data", "cache", null); + LauncherStartupOptions options = LauncherStartupOptions.Parse( + ["--update-manifest-uri", "https://updates.example.test/manifest.json"], + () => paths); + + Program.RequireUnchangedPublicArguments( + options, + new SelfUpdateStartupResult( + false, + 0, + options.PublicArguments.ToArray())); + Assert.Throws(() => + Program.RequireUnchangedPublicArguments( + options, + new SelfUpdateStartupResult( + false, + 0, + ["--update-manifest-uri", "https://other.example.test/manifest.json"]))); + } + public static TheoryData InvalidArguments() { string absolute = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "acdream-la11")); @@ -171,6 +243,10 @@ public sealed class LauncherStartupOptionsTests data.Add(["--update-manifest-uri", "file:///tmp/manifest.json"]); data.Add( ["--update-manifest-uri", "https://user:secret@example.test/manifest.json"]); + data.Add( + ["--update-manifest-uri", "https://example.test/manifest.json?token=secret"]); + data.Add( + ["--update-manifest-uri", "https://example.test/manifest.json#fragment"]); data.Add(["--update-manifest-uri", "not-a-uri"]); data.Add( [ diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs index 37365efe..d2f2ed98 100644 --- a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs @@ -62,4 +62,44 @@ public sealed class LauncherUpdateCompositionTests : IDisposable () => composition.Updater.CheckAsync()); Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); } + + [Theory] + [InlineData("https://updates.example.test/manifest.json")] + [InlineData("http://127.0.0.1:43119/manifest.json")] + public void ProcessLocalManifestOverrideReachesOnlyUpdateComposition(string value) + { + Directory.CreateDirectory(_root); + var paths = new ApplicationPathSet( + Path.Combine(_root, "config"), + Path.Combine(_root, "data"), + Path.Combine(_root, "cache"), + null); + var manifestUri = new Uri(value); + + using LauncherUpdateComposition composition = LauncherUpdateComposition.Create( + paths, + LauncherRuntimeIdentity.DetectRid(), + LauncherVersion.Parse("1.0.0"), + _root, + () => false, + initialize: (_, _) => new ClientVersionResolution( + ClientVersionState.Missing, + "No client version is installed.", + null, + null, + null, + null), + updateManifestUri: manifestUri); + + Assert.Same(manifestUri, composition.UpdateManifestUri); + Assert.Equal( + Path.Combine(paths.DataDirectory, "app"), + composition.Versions.AppDirectory); + Assert.False(File.Exists( + Path.Combine(paths.ConfigDirectory, "launcher-profiles.json"))); + Assert.Empty(Directory.EnumerateFiles( + _root, + "*", + SearchOption.AllDirectories)); + } } diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 98d129eb..52108e71 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -85,9 +85,11 @@ foreach ($release in @('A', 'B')) { Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe' Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake' } if (Test-Path -LiteralPath $OutputDirectory) { @@ -155,6 +157,7 @@ function New-DeterministicZip( $executable = $relative -ceq 'AcDream.App' -or $relative -ceq 'acdream-headless' -or $relative -ceq 'acdream-launcher' -or + $relative -ceq 'acdream-bake' -or $relative.EndsWith('.sh', [StringComparison]::Ordinal) $mode = if ($executable) { 0x81ED } else { 0x81A4 } $entry.ExternalAttributes = $mode -shl 16 @@ -239,16 +242,26 @@ $server = @' [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Root, - [Parameter(Mandatory = $true)][int]$Port + [Parameter(Mandatory = $true)][int]$Port, + [ValidateRange(0, 1000000)][int]$MaximumRequests = 0 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) $Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing serve-fixture.ps1.' +} $prefix = "http://127.0.0.1:$Port/" $listener = [Net.HttpListener]::new() $listener.Prefixes.Add($prefix) $listener.Start() Write-Host "Campaign LA fixture listening on $prefix" +$servedRequests = 0 try { while ($listener.IsListening) { $context = $listener.GetContext() @@ -287,7 +300,13 @@ try { $context.Response.StatusCode = 500 Write-Error $_ } - finally { $context.Response.Close() } + finally { + $context.Response.Close() + $servedRequests++ + } + if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) { + break + } } } finally { $listener.Close() } @@ -301,8 +320,24 @@ param( [string]$Root = $PSScriptRoot ) Set-StrictMode -Version Latest -$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt' -Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing set-active-release.ps1.' +} +$path = Join-Path $Root 'active-release.txt' +$temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp" +try { + [IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII) + [IO.File]::Move($temporary, $path, $true) +} +finally { + if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } +} Write-Host "Campaign LA fixture active release: $Release" '@ $selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 index 0a676df1..13b376e1 100644 --- a/tools/run-campaign-la-preflight.ps1 +++ b/tools/run-campaign-la-preflight.ps1 @@ -68,16 +68,22 @@ $startedUtc = [DateTime]::UtcNow function Protect-Text([string]$Text) { if ($null -eq $Text) { return '' } $protected = $Text - foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) { - $value = [Environment]::GetEnvironmentVariable($name) - if (-not [string]::IsNullOrEmpty($value)) { - $protected = $protected.Replace($value, '', [StringComparison]::Ordinal) - } - } $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)(--password|-password)(\s+|=)([^\s"'']+)', '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(https?://)[^/\s:@]+:[^@\s/]+@', + '$1@') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+', + '$1') $protected = [Text.RegularExpressions.Regex]::Replace( $protected, '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', @@ -142,6 +148,11 @@ function Invoke-GateCommand( $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + foreach ($key in @($startInfo.Environment.Keys)) { + if ($key.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)) { + $startInfo.Environment.Remove($key) + } + } foreach ($entry in $Environment.GetEnumerator()) { $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value } @@ -258,6 +269,34 @@ try { '--', 'RunConfiguration.MaxCpuCount=1') } + $headlessValidationConfig = Join-Path $OutputDirectory 'headless-k0.json' + Add-InternalCheck 'portable-headless-write-empty-config' { + [IO.File]::WriteAllText( + $headlessValidationConfig, + '{"version":1,"sessions":[]}', + [Text.UTF8Encoding]::new($false)) + } + Invoke-DotNet 'portable-headless-help-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', '--help') + Invoke-DotNet 'portable-headless-validate-empty-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', + 'validate', '--config', $headlessValidationConfig) + Add-InternalCheck 'portable-headless-native-permission' { + if (-not $IsWindows) { + $headlessExecutable = Join-Path ` + $Repository 'src/AcDream.Headless/bin/Release/net10.0/acdream-headless' + if (-not (Test-Path -LiteralPath $headlessExecutable -PathType Leaf)) { + throw 'The native Headless build output is missing.' + } + $mode = [IO.File]::GetUnixFileMode($headlessExecutable) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'The native Headless build output is not executable.' + } + } + } + foreach ($rid in @('win-x64', 'linux-x64')) { $destination = Join-Path $publishDirectory $rid Invoke-DotNet "publish-launcher-$rid" @( @@ -382,7 +421,8 @@ finally { failures = @($failures) redaction = [ordered]@{ applied = $true - environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET') + inheritedAcdreamEnvironmentCleared = $true + inheritedEnvironmentValuesRead = $false credentialArgumentsAllowed = $false } artifacts = $artifacts diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 834ff031..8f3b2c61 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -137,7 +137,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { } $eventName = Assert-String $root 'e' if (-not $exactFields.ContainsKey($eventName)) { - throw "event '$eventName' is not in the v1 vocabulary" + throw 'event name is not in the v1 vocabulary' } $expected = $exactFields[$eventName] if ($names.Count -ne $expected.Count -or @@ -221,7 +221,7 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } if ($reason -cne $expectedReason) { - throw "terminal reason is '$reason', expected '$expectedReason'" + throw "terminal reason does not match mode '$Mode'" } $terminalSeen = $true } From accd01a00878643ad1b59be55aca161abf3ce817 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 01:08:41 +0200 Subject: [PATCH 054/138] fix(launcher): harden Campaign LA11 gate evidence --- .../2026-08-14-campaign-la-test-script.md | 80 ++++- .../Updates/LauncherSelfUpdateBootstrap.cs | 221 ++++++++----- .../Updates/LauncherSelfUpdateManager.cs | 33 ++ .../Updates/UpdateSessionBarrier.cs | 37 +++ .../LauncherStartupOptions.cs | 15 - .../Program.cs | 34 +- .../Updates/LauncherSelfUpdateManagerTests.cs | 225 ++++++++++++- .../Updates/LauncherSelfUpdateProcessTests.cs | 189 +++++++++-- .../LauncherStartupOptionsTests.cs | 17 +- tools/CampaignLaProcessCorrelation.ps1 | 93 ++++++ tools/capture-campaign-la-session-process.ps1 | 109 +++++++ tools/new-campaign-la-update-fixture.ps1 | 167 ++++++++-- tools/run-campaign-la-preflight.ps1 | 112 ++++++- tools/test-campaign-la-gate-helpers.ps1 | 296 ++++++++++++++++++ tools/test-campaign-la-script-safety.ps1 | 254 +++++++++++++++ tools/test-campaign-la-session-status.ps1 | 148 +++++++-- 16 files changed, 1820 insertions(+), 210 deletions(-) create mode 100644 tools/CampaignLaProcessCorrelation.ps1 create mode 100644 tools/capture-campaign-la-session-process.ps1 create mode 100644 tools/test-campaign-la-gate-helpers.ps1 create mode 100644 tools/test-campaign-la-script-safety.ps1 diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md index 25109d73..0377c261 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -53,6 +53,7 @@ New-Item -ItemType Directory -Path $Gate | Out-Null pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` -Repository $Repo ` + -AllowedOutputRoot $Gate ` -OutputDirectory $Preflight $Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw | @@ -79,8 +80,13 @@ The expected matrix is: `report.json` records the tested HEAD/dirty state, OS/RID, exact commands, durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight -plans 30 rows. It never launches App or Headless in connected mode and -never reads a credential. Every child starts with all inherited `ACDREAM_*` +plans 32 rows, including the connection-free PID/status/redaction and script- +safety contract suites. `-AllowedOutputRoot` must be a fresh, explicit +`campaign-la-*` gate root (or the repository `logs` root), and output must be a +fresh, empty, non-reparse strict descendant; repository, home, source, payload, +nonempty, and arbitrary existing directories are rejected. The helper never +launches App or Headless in connected mode and never reads a credential. Every +child starts with all inherited `ACDREAM_*` variables removed, so a developer shell cannot accidentally enable live, installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional row below adds the two named DAT variables back for its three exact tests. @@ -93,6 +99,7 @@ the DAT directory may be read by tests: ```powershell pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` -Repository $Repo ` + -AllowedOutputRoot $Gate ` -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') ` -IncludeInstalledDat ` -InstalledDatDirectory '' @@ -103,7 +110,7 @@ The mandatory installed-DAT result is `ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX and fails if the test skipped or did anything other than pass. The action-map and portal-asset probes are additional coverage, never substitutes. Expected -matrix size: 34 rows. +matrix size: 36 rows. On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository path, and a Linux output path. Do not treat a Windows-hosted run over @@ -170,8 +177,11 @@ pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1 ``` The helper rejects nonempty output, invalid or non-monotonic versions, missing -root executables (including the co-deployed Bake CLI), and nonabsolute inputs. -It writes fixed-timestamp sorted ZIPs, +root executables (including the co-deployed Bake CLI), nonabsolute inputs, +output/source overlap in either direction, and any reparse point in source or +output ancestry. It enumerates normalized relative paths with ordinal ordering, +never its own output, and normalizes ZIP host metadata so Windows/Linux hashes +are identical under multiple cultures. It writes fixed-timestamp sorted ZIPs, the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B selector. Both generated helpers reject a `-Root` other than their own fixture @@ -221,24 +231,56 @@ must preserve the same validated suffix through helper and confirmation restarts. The launcher, profiles, installer, current-version store, updater, session composer, and orchestrator must all use this one exact path set. -For every play/probe row, copy the session id shown in the launcher's Sessions -list into ``, then run: +For every play/probe row, start this gate-only PID watcher immediately before +clicking Refresh/Play. It correlates only the unique isolated session-config +path, records neither command line nor config contents, and must finish while +the child is still live: ```powershell -$Status = Join-Path $WinCache 'launcher/sessions//status.jsonl' +$CapturePath = Join-Path $Evidence '-process.capture.json' +$CaptureStart = [DateTimeOffset]::UtcNow +$CaptureInfo = [Diagnostics.ProcessStartInfo]::new() +$CaptureInfo.FileName = (Get-Command pwsh).Source +$CaptureInfo.UseShellExecute = $false +$CaptureInfo.CreateNoWindow = $true +foreach ($Value in @( + '-NoProfile', '-File', (Join-Path $Repo 'tools/capture-campaign-la-session-process.ps1'), + '-SessionsDirectory', (Join-Path $WinCache 'launcher/sessions'), + '-CreatedAfterUtc', $CaptureStart.ToString('O'), + '-ReportPath', $CapturePath, '-WaitSeconds', '60')) { + $CaptureInfo.ArgumentList.Add($Value) +} +$CaptureProcess = [Diagnostics.Process]::Start($CaptureInfo) +# Click exactly one Refresh/Play action now, then wait for capture. +$CaptureProcess.WaitForExit() +if ($CaptureProcess.ExitCode) { throw 'Stop: live child PID capture failed.' } +$Capture = Get-Content -LiteralPath $CapturePath -Raw | ConvertFrom-Json +$SessionConfig = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/session.json" +$Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.jsonl" + +# After Stop and terminal status: pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` -StatusFile $Status ` -Mode '' ` - -ExpectedSessionId '' ` + -ExpectedProcessId $Capture.processId ` + -SessionConfigPath $SessionConfig ` + -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` + -ExpectedSessionId $Capture.sessionId ` -ReportPath (Join-Path $Evidence '-status.validation.json') ``` Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact v1 fields **and property order**, one session id, UTC monotonic timestamps, mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login -command failure, credential redaction, and no surviving App/Headless process. -Its report contains event names and a hash, not account, character, command, or -error payloads. Keep raw `session.json`/`status.jsonl` local; never upload them. +command failure, exact terminal `disconnected.reason == stopped`, credential +redaction, and that exact captured PID is gone. Optional config-path correlation +uses Windows CIM or Linux `/proc/*/cmdline`; it never globally scans a process +name, so unrelated same-name processes and Linux's 15-character names do not +affect the result. The validator verifies owner-only profile access, reads only +password/secret fields in memory, recursively checks every allowed status +string (including command/error text), and reports only the forbidden-value +count and status hash—never credential content or a credential hash. Keep the +profile and raw `session.json`/`status.jsonl` local; never upload them. ## 5. Serial Windows user rows A–H @@ -374,8 +416,10 @@ called. This connected row proves the actual ACE graceful-logout half. Issue 2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click Delete, inspect the retail confirmation dialog, cancel once, and confirm no state change. -3. Delete again and confirm. Verify the wait dialog, greyed roster row/countdown, - disabled Enter/Delete, and enabled Restore. Save `G-deleted.png`. +3. Delete again and confirm. Verify the wait dialog, greyed/pending-delete + roster state, constant boolean-ish nonzero `secondsGreyedOut`, disabled + Enter/Delete, and enabled Restore. The UI must display no countdown. Save + `G-deleted.png`. 4. Click Restore and confirm the same GUID returns to ordinary state with Enter/Delete enabled and Restore disabled. Save `G-restored.png`. 5. Close through launcher **Stop**, confirm graceful terminal status and ACE @@ -385,6 +429,9 @@ called. This connected row proves the actual ACE graceful-logout half. Issue pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` -Mode guiSelect ` + -ExpectedProcessId '' ` + -SessionConfigPath (Join-Path $WinCache 'launcher/sessions//session.json') ` + -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectNoEnteredWorld ` -ExpectedSessionId '' ` -ExpectedPlugin acdream.smoke ` @@ -489,7 +536,9 @@ Complete this exact serial matrix: `stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must be `600`. 3. **Probe twice:** run Refresh twice, validate both status streams in `probe` - mode with native `pwsh`, and confirm ACE clears the account after each. + mode with native `pwsh`, using the same pre-action watcher and exact PID, + Linux session-config path, and `$LinuxConfig/launcher-profiles.json`; confirm + ACE clears the account after each. 4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled and show the explicit Modern Runtime Slice-L message. Do not bypass this disablement and do not claim a Linux graphical-client gate. @@ -521,6 +570,7 @@ logs/campaign-la-user-gate-/ evidence/A-install-hashes.json evidence/B-*.png evidence/C-probe-{1,2}-status.validation.json + evidence/*-process.capture.json evidence/D-*.png + D-status.validation.json evidence/E-*.png + E-status.validation.json evidence/F-*.png + F-status.validation.json diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs index c37b51e8..6decb4b9 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -15,10 +15,9 @@ public static class LauncherSelfUpdateBootstrap { public const string HelperArgument = "--acdream-self-update-helper-v1"; public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; - internal const string DeferredArgument = "--acdream-self-update-deferred-v1"; - internal const int DeferredLeaseExitCode = 73; + internal const int UpdateLeaseBusyExitCode = 73; + private const string InternalArgumentPrefix = "--acdream-self-update-"; private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); public static async Task HandleAsync( string[] args, @@ -33,12 +32,6 @@ public static class LauncherSelfUpdateBootstrap Path.GetFullPath(launcherBaseDirectory)); string executable = Path.GetFullPath(currentExecutablePath); - if (args.Length > 0 - && string.Equals(args[0], DeferredArgument, StringComparison.Ordinal)) - { - return new SelfUpdateStartupResult(false, 0, args[1..]); - } - if (args.Length > 0 && string.Equals(args[0], HelperArgument, StringComparison.Ordinal)) { @@ -55,6 +48,8 @@ public static class LauncherSelfUpdateBootstrap int exitCode = await RunHelperAsync( manager, + baseDirectory, + executable, parentPid, args[2], args[3], @@ -72,26 +67,72 @@ public static class LauncherSelfUpdateBootstrap return new SelfUpdateStartupResult(true, 64, []); } + if (manager.Barrier.TryAcquireSession( + out UpdateSessionBarrier.SessionLease? unexpectedSharedLease)) + { + unexpectedSharedLease?.Dispose(); + throw new LauncherUpdateException( + "Self-update confirmation is trusted only while its helper owns " + + "the exclusive update lease."); + } + await manager.ConfirmAsync( args[1], baseDirectory, executable, cancellationToken) .ConfigureAwait(false); - await FinishConfirmedCleanupAsync( - manager, - baseDirectory, - cancellationToken) - .ConfigureAwait(false); + // The helper that owns the exclusive lease observes this durable + // receipt and performs authoritative completion. A later ordinary + // startup also completes it if that helper crashes after receipt. return new SelfUpdateStartupResult(false, 0, args[2..]); } + if (args.Length > 0 + && args[0].StartsWith(InternalArgumentPrefix, StringComparison.Ordinal)) + { + // Internal modes are an exact vocabulary. In particular, an old + // deferred-restart marker must never become an authorization to + // skip a pending recovery state. + return new SelfUpdateStartupResult(true, 64, []); + } + + // Load first: an invalid/ambiguous journal must fail closed even when + // another process currently owns the update barrier. + _ = await manager.LoadPendingAsync(cancellationToken).ConfigureAwait(false); + if (!manager.Barrier.TryAcquireExclusive( out UpdateSessionBarrier.ExclusiveLease? startupLease)) { - // A running session or another launcher is staging. Reading the - // plan is safe, but cleanup or starting a competing helper is not. - return new SelfUpdateStartupResult(false, 0, args); + if (!manager.Barrier.TryAcquireSession( + out UpdateSessionBarrier.SessionLease? sharedLease)) + { + throw new LauncherUpdateException( + "Launcher startup is blocked by an active update or recovery transaction."); + } + + using (sharedLease + ?? throw new InvalidOperationException("Shared startup lease is missing.")) + { + SelfUpdatePlan? blockedPlan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (blockedPlan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } + + ValidateCanonicalStartup(blockedPlan, baseDirectory, executable); + if (blockedPlan.State != SelfUpdatePlanState.Staged) + { + throw new LauncherUpdateException( + $"Self-update state '{blockedPlan.State}' requires exclusive recovery."); + } + + // A verified staged update may wait while an already-running + // session holds the shared lease. No helper is spawned, so a + // late session lease cannot create a restart loop. + return new SelfUpdateStartupResult(false, 0, args); + } } using (UpdateSessionBarrier.ExclusiveLease lease = startupLease @@ -108,11 +149,7 @@ public static class LauncherSelfUpdateBootstrap return new SelfUpdateStartupResult(false, 0, args); } - if (!PathsEqual(plan.TargetDirectory, baseDirectory)) - { - throw new LauncherUpdateException( - "The pending self-update targets a different launcher directory."); - } + ValidateCanonicalStartup(plan, baseDirectory, executable); if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) { @@ -138,13 +175,40 @@ public static class LauncherSelfUpdateBootstrap return new SelfUpdateStartupResult(false, 0, args); } - string expectedExecutable = ClientVersionStore.ResolveContained( - baseDirectory, - GetLauncherFileName(plan.Rid)); - if (!PathsEqual(executable, expectedExecutable)) + if (plan.State is SelfUpdatePlanState.Applying + or SelfUpdatePlanState.RolledBack) + { + if (plan.State == SelfUpdatePlanState.Applying) + { + plan = await manager.RecoverApplyingAsync( + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + } + + if (plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The interrupted self-update did not produce a rollback receipt."); + } + + await manager.CompleteRolledBackAsync( + plan.TransactionId, + baseDirectory, + lease, + cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + pending: null, + baseDirectory, + lease); + return new SelfUpdateStartupResult(false, 0, args); + } + + if (plan.State != SelfUpdatePlanState.Staged) { throw new LauncherUpdateException( - "Self-update can start only from the published acdream-launcher executable."); + $"Self-update state '{plan.State}' cannot start a helper."); } string helperPath = manager.GetStagedLauncherPath(plan); @@ -173,6 +237,8 @@ public static class LauncherSelfUpdateBootstrap private static async Task RunHelperAsync( LauncherSelfUpdateManager manager, + string helperBaseDirectory, + string currentExecutablePath, int parentPid, string targetDirectory, string transactionId, @@ -182,10 +248,11 @@ public static class LauncherSelfUpdateBootstrap SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) .ConfigureAwait(false) ?? throw new LauncherUpdateException("The helper found no pending self-update."); - if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)) + if (plan.State != SelfUpdatePlanState.Staged + || !string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)) { throw new LauncherUpdateException( - "The helper transaction does not match the pending self-update."); + "The helper mode does not match a staged self-update transaction."); } if (!PathsEqual(plan.TargetDirectory, targetDirectory)) @@ -194,6 +261,15 @@ public static class LauncherSelfUpdateBootstrap "The helper target does not match the pending self-update."); } + string expectedHelperDirectory = manager.GetPayloadDirectory(plan.TransactionId); + string expectedHelperPath = manager.GetStagedLauncherPath(plan); + if (!PathsEqual(helperBaseDirectory, expectedHelperDirectory) + || !PathsEqual(currentExecutablePath, expectedHelperPath)) + { + throw new LauncherUpdateException( + "Self-update helper mode is trusted only from the staged launcher payload."); + } + string launcherPath = ClientVersionStore.ResolveContained( targetDirectory, GetLauncherFileName(plan.Rid)); @@ -215,9 +291,10 @@ public static class LauncherSelfUpdateBootstrap { // Do not restart the canonical launcher: it would immediately see // the same staged plan and create an unbounded helper loop. - return DeferredLeaseExitCode; + return UpdateLeaseBusyExitCode; } + ProcessStartInfo? restoredStart = null; using (UpdateSessionBarrier.ExclusiveLease lease = updateLease ?? throw new InvalidOperationException("Exclusive update lease is missing.")) { @@ -225,7 +302,8 @@ public static class LauncherSelfUpdateBootstrap .ConfigureAwait(false) ?? throw new LauncherUpdateException( "The helper found no pending self-update after acquiring the lease."); - if (!string.Equals( + if (plan.State != SelfUpdatePlanState.Staged + || !string.Equals( plan.TransactionId, transactionId, StringComparison.Ordinal) @@ -315,78 +393,59 @@ public static class LauncherSelfUpdateBootstrap return 75; } - var restored = new ProcessStartInfo(launcherPath) + restoredStart = new ProcessStartInfo(launcherPath) { UseShellExecute = false, WorkingDirectory = Path.GetFullPath(targetDirectory), }; - restored.ArgumentList.Add(DeferredArgument); foreach (string argument in publicArguments) { - restored.ArgumentList.Add(argument); + restoredStart.ArgumentList.Add(argument); } - - _ = Process.Start(restored); - return 74; } finally { replacement?.Dispose(); } } - } - private static async Task FinishConfirmedCleanupAsync( - LauncherSelfUpdateManager manager, - string targetDirectory, - CancellationToken cancellationToken) - { - DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout; - do + // Release the helper's exclusive barrier before restarting the + // restored canonical launcher. It will observe the durable RolledBack + // receipt through the ordinary startup path, re-verify it, finalize + // recovery, and continue with no privileged bypass argument. + if (restoredStart is null || Process.Start(restoredStart) is null) { - cancellationToken.ThrowIfCancellationRequested(); - if (manager.Barrier.TryAcquireExclusive( - out UpdateSessionBarrier.ExclusiveLease? lease)) - { - using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease - ?? throw new InvalidOperationException( - "Exclusive cleanup lease is missing.")) - { - SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken) - .ConfigureAwait(false); - if (pending is - { - State: SelfUpdatePlanState.AwaitingConfirmation, - } - && manager.IsConfirmed(pending.TransactionId)) - { - await manager.CompleteConfirmedAsync( - pending.TransactionId, - targetDirectory, - cancellationToken) - .ConfigureAwait(false); - pending = null; - } - - if (manager.CleanupOwnedResidueUnderLease( - pending, - targetDirectory, - acquiredLease)) - { - return; - } - } - } - - await Task.Delay(50, cancellationToken).ConfigureAwait(false); + return 75; } - while (DateTimeOffset.UtcNow < deadline); + + return 74; } private static string GetLauncherFileName(string rid) => "acdream-launcher" + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + private static void ValidateCanonicalStartup( + SelfUpdatePlan plan, + string baseDirectory, + string executable) + { + if (!PathsEqual(plan.TargetDirectory, baseDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update targets a different launcher directory."); + } + + string expectedExecutable = ClientVersionStore.ResolveContained( + baseDirectory, + GetLauncherFileName(plan.Rid)); + if (!PathsEqual(executable, expectedExecutable)) + { + throw new LauncherUpdateException( + "Self-update can run only from the published acdream-launcher executable."); + } + } + private static async Task WaitForParentExitAsync( int parentPid, CancellationToken cancellationToken) diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs index 4d36a2bb..e5f1d467 100644 --- a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -483,6 +483,39 @@ public sealed class LauncherSelfUpdateManager SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); } + /// + /// Finalizes a durable rollback only after the prior owned launcher set + /// has been freshly re-verified while the caller holds the update + /// barrier. A failed self-update is abandoned rather than silently + /// re-staged, so an ordinary restart cannot enter an automatic retry + /// loop. + /// + internal async Task CompleteRolledBackAsync( + string transactionId, + string expectedTargetDirectory, + UpdateSessionBarrier.ExclusiveLease lease, + CancellationToken cancellationToken = default) + { + Barrier.RequireOwned(lease); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no rolled-back self-update."); + ValidatePlan(plan, expectedTarget); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The self-update does not have the expected rollback receipt."); + } + + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan)); + SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); + } + public async Task RollbackAwaitingConfirmationAsync( string expectedTargetDirectory, CancellationToken cancellationToken = default) diff --git a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs index 1ea640fb..fbb21197 100644 --- a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -28,6 +28,43 @@ public sealed class UpdateSessionBarrier return new SessionLease(stream); } + /// + /// Non-blocking shared-lease probe used only by launcher startup after an + /// exclusive probe observed contention. Success proves that no updater + /// owns the exclusive lease at that instant; permission and path failures + /// remain hard errors. + /// + public bool TryAcquireSession(out SessionLease? lease) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + lease = new SessionLease( + new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.ReadWrite, + bufferSize: 1, + FileOptions.None)); + return true; + } + catch (IOException) + { + lease = null; + return false; + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + public ExclusiveLease AcquireExclusive() { FileStream stream = Open( diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs index 43b3429e..b3364cbc 100644 --- a/src/AcDream.Launcher/LauncherStartupOptions.cs +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -9,7 +9,6 @@ internal enum LauncherStartupMode VerifyPublish, SelfUpdateHelper, SelfUpdateConfirmation, - SelfUpdateDeferred, } /// @@ -19,12 +18,6 @@ internal enum LauncherStartupMode /// internal sealed class LauncherStartupOptions { - // This prefix is consumed only after LauncherSelfUpdateBootstrap has - // already decided to continue after a recovered rollback. Keep it local - // so the process-level bootstrap can remain internal to Launcher.Core. - private const string DeferredSelfUpdateArgument = - "--acdream-self-update-deferred-v1"; - private readonly IReadOnlyList _publicArguments; private LauncherStartupOptions( @@ -213,14 +206,6 @@ internal sealed class LauncherStartupOptions arguments.Count >= 2 ? 2 : arguments.Count); } - if (string.Equals( - arguments[0], - DeferredSelfUpdateArgument, - StringComparison.Ordinal)) - { - return (LauncherStartupMode.SelfUpdateDeferred, 1); - } - return (LauncherStartupMode.Desktop, 0); } diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs index 18a98d1e..f847916b 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/Program.cs @@ -31,7 +31,7 @@ if (!string.IsNullOrWhiteSpace(selfUpdateData) SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync( effectiveArgs, manager, - Path.GetFullPath(selfUpdateTarget), + Path.GetFullPath(AppContext.BaseDirectory), Path.GetFullPath( Environment.ProcessPath ?? throw new InvalidOperationException("Process path is unavailable."))); @@ -53,6 +53,8 @@ return effectiveArgs.FirstOrDefault() switch "stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]), "bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]), "canonical-probe" => CanonicalProbe(effectiveArgs[1..]), + "hold-campaign-la-process" => + await HoldCampaignLaProcessAsync(effectiveArgs[1..]), _ => 2, }; @@ -60,7 +62,7 @@ static bool IsBootstrapInvocation(string[] arguments) => arguments.Length > 0 && arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument or LauncherSelfUpdateBootstrap.ConfirmArgument - or LauncherSelfUpdateBootstrap.DeferredArgument + or "--acdream-self-update-deferred-v1" or "canonical-probe"; static ApplicationPathSet Paths(string dataDirectory) @@ -180,6 +182,34 @@ static int CanonicalProbe(string[] arguments) return 0; } +static async Task HoldCampaignLaProcessAsync(string[] arguments) +{ + if (arguments.Length != 4 + || arguments[0] is not ("--config" or "--session-config")) + { + return 2; + } + + string configPath = Path.GetFullPath(arguments[1]); + string readyPath = Path.GetFullPath(arguments[2]); + string releasePath = Path.GetFullPath(arguments[3]); + if (!File.Exists(configPath)) + { + return 3; + } + + File.WriteAllText( + readyPath, + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + while (!File.Exists(releasePath)) + { + await Task.Delay(10); + } + + return 0; +} + static async Task HoldUpdateLeaseAsync(string[] arguments) { if (arguments.Length != 4 diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs index 40fe89f3..4463dd1a 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateManagerTests.cs @@ -295,29 +295,228 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable Assert.Equal(publicArguments, ordinary.RemainingArguments); SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync( - [LauncherSelfUpdateBootstrap.DeferredArgument, .. publicArguments], + ["--acdream-self-update-deferred-v1", .. publicArguments], harness.Manager, harness.Target, harness.LauncherPath); - Assert.False(deferred.ShouldExit); - Assert.Equal(publicArguments, deferred.RemainingArguments); + Assert.True(deferred.ShouldExit); + Assert.Equal(64, deferred.ExitCode); + Assert.Empty(deferred.RemainingArguments); _ = await harness.StageAsync(); SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target); - SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( - [ - LauncherSelfUpdateBootstrap.ConfirmArgument, - applied.TransactionId, - .. publicArguments, - ], + SelfUpdateStartupResult confirmation; + using (UpdateSessionBarrier.ExclusiveLease helperLease = + harness.Manager.Barrier.AcquireExclusive()) + { + confirmation = await LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.ConfirmArgument, + applied.TransactionId, + .. publicArguments, + ], + harness.Manager, + harness.Target, + harness.LauncherPath); + } + + Assert.False(confirmation.ShouldExit); + Assert.Equal(publicArguments, confirmation.RemainingArguments); + Assert.True(File.Exists(harness.Manager.PendingPlanPath)); + Assert.True(harness.Manager.IsConfirmed(applied.TransactionId)); + await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target); + Assert.False(File.Exists(harness.Manager.PendingPlanPath)); + } + + [Fact] + public async Task ContendedOrdinaryStartupAllowsOnlyNoPlanOrValidatedStagedPlan() + { + using var harness = new Harness(_root); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + SelfUpdateStartupResult empty = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.False(empty.ShouldExit); + } + + _ = await harness.StageAsync(); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + SelfUpdateStartupResult staged = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.False(staged.ShouldExit); + } + + SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + SelfUpdatePlan rolledBack = await harness.Manager + .RollbackAwaitingConfirmationAsync(harness.Target); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying"); + using (UpdateSessionBarrier.SessionLease session = + harness.Manager.Barrier.AcquireSession()) + { + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, awaiting.State); + Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State); + } + + [Fact] + public async Task OrdinaryStartupRecoversApplyingAndFinalizesVerifiedRollback() + { + using var harness = new Harness(_root); + _ = await harness.StageAsync(); + _ = await harness.Manager.ApplyPendingAsync(harness.Target); + await SetPlanStateAsync(harness.Manager.PendingPlanPath, "applying"); + + SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync( + ["ordinary"], harness.Manager, harness.Target, harness.LauncherPath); - Assert.False(confirmation.ShouldExit); - Assert.Equal(publicArguments, confirmation.RemainingArguments); - Assert.False(File.Exists(harness.Manager.PendingPlanPath)); - Assert.False(harness.Manager.IsConfirmed(applied.TransactionId)); + Assert.False(result.ShouldExit); + Assert.Equal(["ordinary"], result.RemainingArguments); + Assert.Null(await harness.Manager.LoadPendingAsync()); + Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); + Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); + } + + [Fact] + public async Task InternalPrefixSpoofsCannotCrossPlanStateOrExecutableTrust() + { + using var harness = new Harness(_root); + _ = await harness.StageAsync(); + SelfUpdatePlan staged = Assert.IsType( + await harness.Manager.LoadPendingAsync()); + + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString( + System.Globalization.CultureInfo.InvariantCulture), + harness.Target, + staged.TransactionId, + ], + harness.Manager, + harness.Target, + harness.LauncherPath)); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.ConfirmArgument, staged.TransactionId], + harness.Manager, + harness.Target, + harness.LauncherPath)); + + SelfUpdatePlan awaiting = await harness.Manager.ApplyPendingAsync(harness.Target); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.ConfirmArgument, awaiting.TransactionId], + harness.Manager, + harness.Target, + Path.Combine(harness.Target, "spoof-launcher"))); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString( + System.Globalization.CultureInfo.InvariantCulture), + harness.Target, + awaiting.TransactionId, + ], + harness.Manager, + harness.Target, + harness.LauncherPath)); + + SelfUpdatePlan rolledBack = await harness.Manager + .RollbackAwaitingConfirmationAsync(harness.Target); + foreach (string prefix in new[] + { + LauncherSelfUpdateBootstrap.HelperArgument, + LauncherSelfUpdateBootstrap.ConfirmArgument, + }) + { + string[] arguments = prefix == LauncherSelfUpdateBootstrap.HelperArgument + ? [prefix, int.MaxValue.ToString(), harness.Target, rolledBack.TransactionId] + : [prefix, rolledBack.TransactionId]; + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + arguments, + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + SelfUpdateStartupResult deferred = await LauncherSelfUpdateBootstrap.HandleAsync( + ["--acdream-self-update-deferred-v1"], + harness.Manager, + harness.Target, + harness.LauncherPath); + Assert.True(deferred.ShouldExit); + Assert.Equal(64, deferred.ExitCode); + + await File.WriteAllTextAsync(harness.Manager.PendingPlanPath, "{ambiguous"); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [LauncherSelfUpdateBootstrap.ConfirmArgument, rolledBack.TransactionId], + harness.Manager, + harness.Target, + harness.LauncherPath)); + await Assert.ThrowsAsync(() => + LauncherSelfUpdateBootstrap.HandleAsync( + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString(), + harness.Target, + rolledBack.TransactionId, + ], + harness.Manager, + harness.Target, + harness.LauncherPath)); + } + + private static async Task SetPlanStateAsync(string path, string state) + { + JsonObject plan = Assert.IsType(JsonNode.Parse( + await File.ReadAllTextAsync(path))); + plan["state"] = state; + await File.WriteAllTextAsync(path, plan.ToJsonString()); } private sealed class Harness : IDisposable diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs index 5185fc7d..f63b5638 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LauncherSelfUpdateProcessTests.cs @@ -27,7 +27,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable } [Fact] - public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically() + public async Task KilledApplyingPlanRecoversPriorAndContinuesCanonicalWithoutRetryLoop() { string data = Path.Combine(_root, "data"); string target = Path.Combine(_root, "launcher"); @@ -94,13 +94,21 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable "The self-update journal did not converge."); Assert.Equal( - prepared.NewCanonicalHash, + oldHash, await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath)); - Assert.True(File.Exists(Path.Combine( + Assert.False(File.Exists(Path.Combine( target, LauncherSelfUpdateManager.InstallRecordFileName))); Assert.False(Directory.Exists(manager.GetTransactionDirectory( plan.TransactionId))); + using (UpdateSessionBarrier.ExclusiveLease cleanupLease = + manager.Barrier.AcquireExclusive()) + { + Assert.True(manager.CleanupOwnedResidueUnderLease( + pending: null, + target, + cleanupLease)); + } Assert.Empty(Directory.EnumerateDirectories( target, ".acdream-self-update-*", @@ -113,11 +121,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable launchMarker, StringComparison.Ordinal); int replacementPid = ParsePid(launchMarker); - int helperPid = int.Parse( - await File.ReadAllTextAsync(helperPidPath), - System.Globalization.CultureInfo.InvariantCulture); await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10)); - await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10)); + Assert.False(File.Exists(helperPidPath)); if (OperatingSystem.IsLinux()) { Assert.True( @@ -156,13 +161,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ["canonical-probe", launched], BootstrapEnvironment(crashed, helperPidPath)); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); - Assert.Equal(0, canonical.ExitCode); - await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); - await WaitForProcessExitAsync( - int.Parse( - await File.ReadAllTextAsync(helperPidPath), - System.Globalization.CultureInfo.InvariantCulture), - TimeSpan.FromSeconds(20)); + Assert.NotEqual(0, canonical.ExitCode); + Assert.False(File.Exists(helperPidPath)); Assert.False(File.Exists(launched)); SelfUpdatePlan preserved = Assert.IsType( @@ -204,13 +204,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ["canonical-probe", launched], BootstrapEnvironment(crashed, helperPidPath)); await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20)); - Assert.Equal(0, canonical.ExitCode); - await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20)); - await WaitForProcessExitAsync( - int.Parse( - await File.ReadAllTextAsync(helperPidPath), - System.Globalization.CultureInfo.InvariantCulture), - TimeSpan.FromSeconds(20)); + Assert.NotEqual(0, canonical.ExitCode); + Assert.False(File.Exists(helperPidPath)); Assert.False(File.Exists(launched)); Assert.True(File.Exists(outsideCanonical)); @@ -297,8 +292,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ["bootstrap-probe", data, target, canonical, resultPath]); await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal(0, startup.ExitCode); - Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath)); + Assert.NotEqual(0, startup.ExitCode); + Assert.False(File.Exists(resultPath)); Assert.True(Directory.Exists(transaction)); Assert.False(File.Exists(observer.PendingPlanPath)); @@ -328,9 +323,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable string helperPid = Path.Combine(_root, "helper.pid"); Directory.CreateDirectory(target); string rid = LauncherRuntimeIdentity.DetectRid(); - string canonical = Path.Combine(target, LauncherName(rid)); - await File.WriteAllTextAsync(canonical, "old-launcher"); - byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher"); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + string canonical = prepared.CanonicalPath; + byte[] archive = prepared.NewArchive; using var server = new LocalHttpFixture(); server.Add("launcher.zip", archive); using var http = new HttpClient(); @@ -354,7 +349,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable [HelperPidEnvironment] = helperPid, }; - using Process helper = StartFixture( + using Process helper = StartProcess( + manager.GetStagedLauncherPath(plan), [ LauncherSelfUpdateBootstrap.HelperArgument, int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture), @@ -365,16 +361,151 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable ], environment); await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); - Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode); + string helperError = await helper.StandardError.ReadToEndAsync(); + string helperOutput = await helper.StandardOutput.ReadToEndAsync(); + Assert.True( + helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode, + $"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}"); Assert.True(File.Exists(helperPid)); Assert.False(File.Exists(unexpectedLaunch)); - Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical)); + Assert.NotEqual( + prepared.NewCanonicalHash, + await FileIntegrity.ComputeSha256HexAsync(canonical)); SelfUpdatePlan deferred = Assert.IsType( await manager.LoadPendingAsync()); Assert.Equal(SelfUpdatePlanState.Staged, deferred.State); Assert.Equal(plan.TransactionId, deferred.TransactionId); } + [Fact] + public async Task SpoofedInternalPrefixesCannotBypassAnyDurablePlanState() + { + string data = Path.Combine(_root, "data"); + string target = Path.Combine(_root, "launcher"); + string rid = LauncherRuntimeIdentity.DetectRid(); + PreparedLauncher prepared = PrepareLauncherClosure(target, rid); + using var server = new LocalHttpFixture(); + server.Add("launcher.zip", prepared.NewArchive); + using var http = new HttpClient(); + var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http); + _ = await manager.StageAsync( + LauncherVersion.Parse("2.0.0"), + rid, + new ReleaseArtifact( + server.UriFor("launcher.zip"), + UpdateTestData.Sha256(prepared.NewArchive), + prepared.NewArchive.LongLength), + target, + progress: null, + CancellationToken.None); + SelfUpdatePlan plan = Assert.IsType(await manager.LoadPendingAsync()); + var environment = new Dictionary + { + [DataEnvironment] = data, + [TargetEnvironment] = target, + }; + + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "staged"); + + plan = await manager.ApplyPendingAsync(target); + await SetPlanStateAsync(manager.PendingPlanPath, "applying"); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "applying"); + + plan = await manager.RecoverApplyingAsync(target); + plan = await manager.ApplyPendingAsync(target); + Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, plan.State); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "awaitingConfirmation"); + + plan = await manager.RollbackAwaitingConfirmationAsync(target); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "rolledBack"); + + await File.WriteAllTextAsync(manager.PendingPlanPath, "{ambiguous"); + await AssertInternalSpoofsRejectedAsync( + prepared.CanonicalPath, + target, + plan.TransactionId, + environment, + "ambiguous"); + } + + private static async Task AssertInternalSpoofsRejectedAsync( + string canonicalPath, + string targetDirectory, + string transactionId, + IReadOnlyDictionary environment, + string state) + { + (string Name, string[] Arguments, int? ExactExit)[] attempts = + [ + ( + "deferred", + ["--acdream-self-update-deferred-v1"], + 64), + ( + "helper", + [ + LauncherSelfUpdateBootstrap.HelperArgument, + int.MaxValue.ToString( + System.Globalization.CultureInfo.InvariantCulture), + targetDirectory, + transactionId, + ], + null), + ( + "confirm", + [LauncherSelfUpdateBootstrap.ConfirmArgument, transactionId], + null), + ]; + + foreach ((string name, string[] arguments, int? exactExit) in attempts) + { + using Process process = StartProcess(canonicalPath, arguments, environment); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + string stderr = await process.StandardError.ReadToEndAsync(); + if (exactExit.HasValue) + { + Assert.True( + process.ExitCode == exactExit.Value, + $"{state}/{name} exited {process.ExitCode}: {stderr}"); + } + else + { + Assert.True( + process.ExitCode != 0, + $"{state}/{name} unexpectedly succeeded."); + } + } + } + + private static async Task SetPlanStateAsync(string path, string state) + { + System.Text.Json.Nodes.JsonObject plan = Assert.IsType< + System.Text.Json.Nodes.JsonObject>( + System.Text.Json.Nodes.JsonNode.Parse(await File.ReadAllTextAsync(path))); + plan["state"] = state; + await File.WriteAllTextAsync(path, plan.ToJsonString()); + } + private PreparedLauncher PrepareLauncherClosure(string target, string rid) { string fixtureDirectory = GetFixtureDirectory(); diff --git a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs index d7f223ce..7b8f5b07 100644 --- a/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherStartupOptionsTests.cs @@ -147,7 +147,7 @@ public sealed class LauncherStartupOptionsTests } [Fact] - public void DeferredSelfUpdateRestartRetainsIsolationWithoutResolvingDefaults() + public void LegacyDeferredSelfUpdatePrefixIsRejectedAsUntrustedInput() { string root = Path.GetFullPath( Path.Combine(Path.GetTempPath(), "acdream-la11-deferred")); @@ -159,16 +159,11 @@ public sealed class LauncherStartupOptionsTests "--update-manifest-uri", "http://127.0.0.1:43119/manifest.json", ]; - LauncherStartupOptions options = LauncherStartupOptions.Parse( - ["--acdream-self-update-deferred-v1", .. suffix], - () => throw new InvalidOperationException( - "canonical path resolver was touched")); - - Assert.Equal(LauncherStartupMode.SelfUpdateDeferred, options.Mode); - Assert.Equal(suffix, options.PublicArguments); - Assert.Equal(Path.Combine(root, "config"), options.Paths.ConfigDirectory); - Assert.Equal(Path.Combine(root, "data"), options.Paths.DataDirectory); - Assert.Equal(Path.Combine(root, "cache"), options.Paths.CacheDirectory); + Assert.Throws(() => + LauncherStartupOptions.Parse( + ["--acdream-self-update-deferred-v1", .. suffix], + () => throw new InvalidOperationException( + "canonical path resolver was touched"))); } [Fact] diff --git a/tools/CampaignLaProcessCorrelation.ps1 b/tools/CampaignLaProcessCorrelation.ps1 new file mode 100644 index 00000000..00718f13 --- /dev/null +++ b/tools/CampaignLaProcessCorrelation.ps1 @@ -0,0 +1,93 @@ +Set-StrictMode -Version Latest + +function Get-CampaignLaSessionProcessCorrelations { + [CmdletBinding()] + param() + + $matches = [Collections.Generic.List[object]]::new() + if ($IsWindows) { + $pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))' + foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { + $commandLine = [string]$candidate.CommandLine + if ([string]::IsNullOrWhiteSpace($commandLine)) { continue } + foreach ($match in [Text.RegularExpressions.Regex]::Matches( + $commandLine, + $pattern)) { + $value = if ($match.Groups[1].Success) { + $match.Groups[1].Value + } else { $match.Groups[2].Value } + if ([IO.Path]::IsPathFullyQualified($value)) { + $matches.Add([pscustomobject]@{ + ProcessId = [int]$candidate.ProcessId + SessionConfigPath = [IO.Path]::GetFullPath($value) + }) + } + } + } + } + elseif ($IsLinux) { + foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) { + $leaf = [IO.Path]::GetFileName($directory) + $processId = 0 + if (-not [int]::TryParse( + $leaf, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processId)) { + continue + } + try { + $bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline')) + if ($bytes.Length -eq 0) { continue } + $arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split( + [char]0, + [StringSplitOptions]::RemoveEmptyEntries)) + for ($index = 0; $index + 1 -lt $arguments.Count; $index++) { + if ($arguments[$index] -cin @('--config', '--session-config') -and + [IO.Path]::IsPathFullyQualified($arguments[$index + 1])) { + $matches.Add([pscustomobject]@{ + ProcessId = $processId + SessionConfigPath = [IO.Path]::GetFullPath( + $arguments[$index + 1]) + }) + } + } + } + catch [IO.IOException] { + # A process may exit between /proc enumeration and cmdline read. + } + catch [UnauthorizedAccessException] { + # Other-user processes cannot be the owner-readable gate child. + } + } + } + else { + throw 'Campaign LA process correlation supports Windows and Linux only.' + } + + return @($matches) +} + +function Get-CampaignLaCorrelatedProcessIds { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$SessionConfigPath) + + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw 'Session-config correlation requires an absolute path.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $matches = [Collections.Generic.HashSet[int]]::new() + foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { + if ([string]::Equals( + $candidate.SessionConfigPath, + $SessionConfigPath, + $comparison)) { + $null = $matches.Add([int]$candidate.ProcessId) + } + } + + return @($matches | Sort-Object) +} diff --git a/tools/capture-campaign-la-session-process.ps1 b/tools/capture-campaign-la-session-process.ps1 new file mode 100644 index 00000000..448660d4 --- /dev/null +++ b/tools/capture-campaign-la-session-process.ps1 @@ -0,0 +1,109 @@ +<# +.SYNOPSIS + Captures one launcher child PID by its unique isolated session-config path. + +.DESCRIPTION + Writes a sanitized gate-only sidecar. It never reads the session-config + contents and records no command line, account, character, or credential. +#> +[CmdletBinding(DefaultParameterSetName = 'Path')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Path')] + [string]$SessionConfigPath, + [Parameter(Mandatory = $true, ParameterSetName = 'Directory')] + [string]$SessionsDirectory, + [Parameter(ParameterSetName = 'Directory')] + [DateTimeOffset]$CreatedAfterUtc = [DateTimeOffset]::MinValue, + [Parameter(Mandatory = $true)][string]$ReportPath, + [ValidateRange(1, 60)][int]$WaitSeconds = 10 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA PID capture requires PowerShell 7 or newer.' +} +. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1') + +if ($PSCmdlet.ParameterSetName -eq 'Path') { + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw '-SessionConfigPath must be absolute.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + if (-not (Test-Path -LiteralPath $SessionConfigPath -PathType Leaf)) { + throw "Session config does not exist: $SessionConfigPath" + } +} +else { + if (-not [IO.Path]::IsPathFullyQualified($SessionsDirectory)) { + throw '-SessionsDirectory must be absolute.' + } + $SessionsDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($SessionsDirectory)) + if (-not (Test-Path -LiteralPath $SessionsDirectory -PathType Container)) { + throw "Sessions directory does not exist: $SessionsDirectory" + } +} +if (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { + throw '-ReportPath must be absolute.' +} +$ReportPath = [IO.Path]::GetFullPath($ReportPath) +if (Test-Path -LiteralPath $ReportPath) { + throw '-ReportPath must be fresh.' +} + +$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds) +do { + if ($PSCmdlet.ParameterSetName -eq 'Path') { + $correlations = @(Get-CampaignLaSessionProcessCorrelations | + Where-Object { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + [string]::Equals( + $_.SessionConfigPath, + $SessionConfigPath, + $comparison) + }) + } + else { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $prefix = $SessionsDirectory + [IO.Path]::DirectorySeparatorChar + $correlations = @(Get-CampaignLaSessionProcessCorrelations | + Where-Object { + $_.SessionConfigPath.StartsWith($prefix, $comparison) -and + [IO.Path]::GetFileName($_.SessionConfigPath) -ceq 'session.json' -and + (Test-Path -LiteralPath $_.SessionConfigPath -PathType Leaf) -and + (Get-Item -LiteralPath $_.SessionConfigPath).LastWriteTimeUtc -ge + $CreatedAfterUtc.UtcDateTime + }) + } + if ($correlations.Count -eq 1) { break } + if ($correlations.Count -gt 1) { + throw "More than one process uses the isolated session config." + } + Start-Sleep -Milliseconds 100 +} while ([DateTime]::UtcNow -lt $deadline) +if ($correlations.Count -ne 1) { + throw 'No live process uses the isolated session config.' +} +$SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath) + +$directory = Split-Path -Parent $ReportPath +if (-not [string]::IsNullOrEmpty($directory)) { + $null = New-Item -ItemType Directory -Force -Path $directory +} +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-session-process-capture' + processId = [int]$correlations[0].ProcessId + sessionId = [IO.Path]::GetFileName( + [IO.Path]::GetDirectoryName($SessionConfigPath)) + sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath) + capturedUtc = [DateTime]::UtcNow.ToString('O') +} +$report | ConvertTo-Json -Depth 3 | + Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM +Write-Host "Campaign LA process capture: $ReportPath" diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 52108e71..2e8723f8 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -37,6 +37,35 @@ if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { } $OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($OutputDirectory)) + +function Assert-NoReparseAncestry([string]$Path, [string]$Description) { + $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + while (-not (Test-Path -LiteralPath $cursor)) { + $parent = [IO.Path]::GetDirectoryName($cursor) + if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break } + $cursor = $parent + } + while (-not [string]::IsNullOrEmpty($cursor)) { + $item = Get-Item -LiteralPath $cursor -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description has a reparse point in its ancestry." + } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Test-SameOrDescendant([string]$Path, [string]$Ancestor) { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true } + $prefix = $Ancestor + [IO.Path]::DirectorySeparatorChar + return $Path.StartsWith($prefix, $comparison) +} + +Assert-NoReparseAncestry $OutputDirectory 'Output directory' if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' } $semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or @@ -73,6 +102,11 @@ foreach ($key in @($sources.Keys)) { if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) { throw "Payload source '$key' does not exist: $source" } + Assert-NoReparseAncestry $source "Payload source '$key'" + if ((Test-SameOrDescendant $OutputDirectory $source) -or + (Test-SameOrDescendant $source $OutputDirectory)) { + throw "Output directory and payload source '$key' must not overlap." + } } function Require-PayloadFile([string]$Key, [string]$Name) { @@ -98,6 +132,7 @@ if (Test-Path -LiteralPath $OutputDirectory) { } } else { $null = New-Item -ItemType Directory -Path $OutputDirectory } +Assert-NoReparseAncestry $OutputDirectory 'Output directory' if ($DryRun) { $plan = [ordered]@{ @@ -121,6 +156,73 @@ Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem $fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) +function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) { + return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8) +} + +function Get-LittleEndianUInt32([byte[]]$Bytes, [int]$Offset) { + return [uint32]([uint32]$Bytes[$Offset] -bor + ([uint32]$Bytes[$Offset + 1] -shl 8) -bor + ([uint32]$Bytes[$Offset + 2] -shl 16) -bor + ([uint32]$Bytes[$Offset + 3] -shl 24)) +} + +function Set-DeterministicZipHostPlatform([string]$Path) { + [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) + $minimumEocdSize = 22 + if ($bytes.Length -lt $minimumEocdSize) { + throw "Generated ZIP is too short: $Path" + } + + $eocd = -1 + $minimumOffset = [Math]::Max(0, $bytes.Length - 65557) + for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) { + if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) { + $commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20) + if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) { + $eocd = $offset + break + } + } + } + if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" } + if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or + (Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) { + throw "Generated ZIP unexpectedly spans multiple disks: $Path" + } + + $entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8) + $entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10) + if ($entriesOnDisk -ne $entryCount) { + throw "Generated ZIP central-directory count is inconsistent: $Path" + } + $centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12) + $centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16) + if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) { + throw "Generated ZIP central-directory bounds are inconsistent: $Path" + } + + [uint64]$cursor = $centralOffset + for ($index = 0; $index -lt $entryCount; $index++) { + if ($cursor + 46 -gt $eocd -or + (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { + throw "Generated ZIP central-directory entry is invalid: $Path" + } + # ZipArchive intentionally stamps the creating host (FAT on Windows, + # Unix on Linux) in the upper byte of "version made by". Normalize it + # to FAT; permissions are already explicit in ExternalAttributes. + $bytes[[int]$cursor + 5] = 0 + $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) + $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) + $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) + $cursor += 46 + $nameLength + $extraLength + $commentLength + } + if ($cursor -ne $eocd) { + throw "Generated ZIP central-directory length is inconsistent: $Path" + } + [IO.File]::WriteAllBytes($Path, $bytes) +} + function New-DeterministicZip( [string]$SourceDirectory, [string]$Destination, @@ -141,17 +243,30 @@ function New-DeterministicZip( $true, [Text.Encoding]::UTF8) try { - $files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse | - Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') }) - foreach ($file in $files) { - if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Payload contains a reparse point: $($file.FullName)" + $allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse) + foreach ($item in $allEntries) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Payload contains a reparse point: $($item.FullName)" } - $relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/') + } + [string[]]$files = @($allEntries | + Where-Object { -not $_.PSIsContainer } | + ForEach-Object { + [IO.Path]::GetRelativePath( + $SourceDirectory, + $_.FullName).Replace('\', '/') + }) + [Array]::Sort($files, [StringComparer]::Ordinal) + $caseFolded = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + foreach ($relative in $files) { if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or - [IO.Path]::IsPathRooted($relative)) { + [IO.Path]::IsPathRooted($relative) -or + -not $caseFolded.Add($relative)) { throw "Payload path escaped its root: $relative" } + $file = Get-Item -LiteralPath ( + Join-Path $SourceDirectory $relative.Replace('/', [IO.Path]::DirectorySeparatorChar)) $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) $entry.LastWriteTime = $fixedTimestamp $executable = $relative -ceq 'AcDream.App' -or @@ -183,6 +298,7 @@ function New-DeterministicZip( finally { $archive.Dispose() } } finally { $stream.Dispose() } + Set-DeterministicZipHostPlatform $Destination } function Get-Artifact([string]$Path, [string]$Url) { @@ -232,11 +348,15 @@ foreach ($release in $releaseDefinitions) { "$baseUri/launcher-linux-x64.zip" } } - $manifest | ConvertTo-Json -Depth 8 -Compress | - Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM + [IO.File]::WriteAllText( + (Join-Path $releaseRoot 'manifest.json'), + ($manifest | ConvertTo-Json -Depth 8 -Compress), + [Text.UTF8Encoding]::new($false)) } -Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') ` - -Value 'A' -Encoding ascii -NoNewline +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'active-release.txt'), + 'A', + [Text.Encoding]::ASCII) $server = @' [CmdletBinding()] @@ -311,7 +431,10 @@ try { } finally { $listener.Close() } '@ -$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'serve-fixture.ps1'), + $server.Replace("`r`n", "`n"), + [Text.UTF8Encoding]::new($false)) $selector = @' [CmdletBinding()] @@ -340,16 +463,24 @@ finally { } Write-Host "Campaign LA fixture active release: $Release" '@ -$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'set-active-release.ps1'), + $selector.Replace("`r`n", "`n"), + [Text.UTF8Encoding]::new($false)) -$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | +$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | Where-Object { $_.Name -ne 'fixture-report.json' } | - Sort-Object FullName | ForEach-Object { + [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + }) +[Array]::Sort($inventoryPaths, [StringComparer]::Ordinal) +$inventory = @($inventoryPaths | ForEach-Object { + $fullPath = Join-Path $OutputDirectory $_.Replace('/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $fullPath [ordered]@{ - path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') - size = $_.Length - sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + path = $_ + size = $item.Length + sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() } }) $report = [ordered]@{ diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 index 13b376e1..2966ed59 100644 --- a/tools/run-campaign-la-preflight.ps1 +++ b/tools/run-campaign-la-preflight.ps1 @@ -13,6 +13,7 @@ [CmdletBinding()] param( [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$AllowedOutputRoot, [string]$OutputDirectory, [switch]$DryRun, [switch]$IncludeInstalledDat, @@ -30,6 +31,66 @@ $Repository = [IO.Path]::TrimEndingDirectorySeparator( if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) { throw "Repository does not contain AcDream.slnx: $Repository" } + +function Assert-NoReparseAncestry([string]$Path, [string]$Description) { + $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + while (-not (Test-Path -LiteralPath $cursor)) { + $parent = [IO.Path]::GetDirectoryName($cursor) + if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break } + $cursor = $parent + } + while (-not [string]::IsNullOrEmpty($cursor)) { + $item = Get-Item -LiteralPath $cursor -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description has a reparse point in its ancestry." + } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Test-SameOrDescendant([string]$Path, [string]$Ancestor) { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true } + return $Path.StartsWith( + $Ancestor + [IO.Path]::DirectorySeparatorChar, + $comparison) +} + +if (-not [IO.Path]::IsPathFullyQualified($AllowedOutputRoot)) { + throw '-AllowedOutputRoot must be absolute.' +} +$AllowedOutputRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($AllowedOutputRoot)) +if (-not (Test-Path -LiteralPath $AllowedOutputRoot -PathType Container)) { + throw '-AllowedOutputRoot must be an existing campaign gate/log directory.' +} +Assert-NoReparseAncestry $AllowedOutputRoot 'Allowed output root' +$comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +$homeDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath([Environment]::GetFolderPath( + [Environment+SpecialFolder]::UserProfile))) +if ([string]::Equals($AllowedOutputRoot, $Repository, $comparison) -or + [string]::Equals($AllowedOutputRoot, $homeDirectory, $comparison)) { + throw '-AllowedOutputRoot cannot be the repository root or user home.' +} +$repositoryLogs = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath((Join-Path $Repository 'logs'))) +$allowedLeaf = [IO.Path]::GetFileName($AllowedOutputRoot) +$allowedInRepository = Test-SameOrDescendant $AllowedOutputRoot $Repository +if ($allowedInRepository -and + -not (Test-SameOrDescendant $AllowedOutputRoot $repositoryLogs)) { + throw '-AllowedOutputRoot inside the repository must be below its logs directory.' +} +if (-not [string]::Equals($AllowedOutputRoot, $repositoryLogs, $comparison) -and + -not $allowedLeaf.StartsWith('campaign-la-', [StringComparison]::Ordinal)) { + throw '-AllowedOutputRoot must be the repository logs root or a campaign-la-* gate root.' +} if ($IncludeInstalledDat) { if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) { @@ -50,16 +111,28 @@ if ($IncludeInstalledDat) { $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { - $OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp" + $OutputDirectory = Join-Path $AllowedOutputRoot "campaign-la-preflight-$stamp" } elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { - $OutputDirectory = Join-Path $Repository $OutputDirectory + throw '-OutputDirectory must be absolute when supplied.' } $OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( [IO.Path]::GetFullPath($OutputDirectory)) +if (-not (Test-SameOrDescendant $OutputDirectory $AllowedOutputRoot) -or + [string]::Equals($OutputDirectory, $AllowedOutputRoot, $comparison)) { + throw '-OutputDirectory must be a strict descendant of -AllowedOutputRoot.' +} +if ([string]::Equals($OutputDirectory, $Repository, $comparison) -or + [string]::Equals($OutputDirectory, $homeDirectory, $comparison)) { + throw '-OutputDirectory cannot be the repository root or user home.' +} +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh and must not already exist.' +} +Assert-NoReparseAncestry $OutputDirectory 'Output directory' $logsDirectory = Join-Path $OutputDirectory 'commands' $publishDirectory = Join-Path $OutputDirectory 'publish' -$null = New-Item -ItemType Directory -Force -Path $logsDirectory +$null = New-Item -ItemType Directory -Path $logsDirectory $commandResults = [Collections.Generic.List[object]]::new() $failures = [Collections.Generic.List[string]]::new() @@ -245,6 +318,22 @@ $portableTestProjects = @( try { Invoke-DotNet 'release-build' @( 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1') + Invoke-GateCommand 'campaign-la-gate-helper-contracts' ` + ([Environment]::ProcessPath ?? + $(throw 'The PowerShell process path is unavailable.')) ` + @( + '-NoProfile', + '-File', 'tools/test-campaign-la-gate-helpers.ps1', + '-Repository', $Repository, + '-OutputDirectory', (Join-Path $OutputDirectory 'helper-contracts')) + Invoke-GateCommand 'campaign-la-script-safety-contracts' ` + ([Environment]::ProcessPath ?? + $(throw 'The PowerShell process path is unavailable.')) ` + @( + '-NoProfile', + '-File', 'tools/test-campaign-la-script-safety.ps1', + '-Repository', $Repository, + '-OutputDirectory', (Join-Path $OutputDirectory 'script-safety')) Invoke-DotNet 'release-tests-serial' @( 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1', '--', 'RunConfiguration.MaxCpuCount=1') @@ -384,14 +473,20 @@ finally { $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all) $artifacts = @() if (-not $DryRun) { - $artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + [string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } | - Sort-Object FullName | ForEach-Object { + [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + }) + [Array]::Sort($artifactPaths, [StringComparer]::Ordinal) + $artifacts = @($artifactPaths | ForEach-Object { + $fullPath = Join-Path $OutputDirectory $_.Replace( + '/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $fullPath [ordered]@{ - path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') - size = $_.Length - sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + path = $_ + size = $item.Length + sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() } }) } @@ -402,6 +497,7 @@ finally { dryRun = [bool]$DryRun success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0) repository = $Repository + allowedOutputRoot = $AllowedOutputRoot head = $head dirty = ($dirtyLines.Count -gt 0) dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ }) diff --git a/tools/test-campaign-la-gate-helpers.ps1 b/tools/test-campaign-la-gate-helpers.ps1 new file mode 100644 index 00000000..11756555 --- /dev/null +++ b/tools/test-campaign-la-gate-helpers.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS + Connection-free contract tests for Campaign LA gate evidence helpers. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA helper tests require PowerShell 7 or newer.' +} +$Repository = [IO.Path]::GetFullPath($Repository) +if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute.' +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh.' +} +$null = New-Item -ItemType Directory -Path $OutputDirectory +$pwsh = [Environment]::ProcessPath +if ([string]::IsNullOrWhiteSpace($pwsh)) { + throw 'The PowerShell process path is unavailable.' +} +$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1' +$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1' + +function Write-Profile([string]$Path, [string]$Secret) { + $document = [ordered]@{ + version = 1 + servers = @([ordered]@{ + name = 'fixture' + host = '127.0.0.1' + port = 9000 + accounts = @([ordered]@{ + account = 'fixture-account' + password = $Secret + characters = @() + }) + }) + } + [IO.File]::WriteAllText( + $Path, + ($document | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + if ($IsLinux) { + [IO.File]::SetUnixFileMode( + $Path, + [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite) + } +} + +function New-GuiEvents { + $begin = [DateTimeOffset]::ParseExact( + '2026-08-15T10:00:00.0000000+00:00', + 'O', + [Globalization.CultureInfo]::InvariantCulture) + $session = 'fixture-session' + return @( + [ordered]@{ v = 1; e = 'started'; t = $begin.ToString('O'); sessionId = $session }, + [ordered]@{ v = 1; e = 'pluginLoaded'; t = $begin.AddSeconds(1).ToString('O'); sessionId = $session; plugin = 'smoke' }, + [ordered]@{ v = 1; e = 'pluginFailed'; t = $begin.AddSeconds(2).ToString('O'); sessionId = $session; plugin = 'optional'; error = 'allowed fixture failure' }, + [ordered]@{ v = 1; e = 'connected'; t = $begin.AddSeconds(3).ToString('O'); sessionId = $session }, + [ordered]@{ + v = 1; e = 'characterList'; t = $begin.AddSeconds(4).ToString('O') + sessionId = $session; accountName = 'fixture-account'; slotCount = 1 + characters = @([ordered]@{ id = 1342177290; name = 'Fixture'; secondsGreyedOut = 0 }) + }, + [ordered]@{ v = 1; e = 'enteredWorld'; t = $begin.AddSeconds(5).ToString('O'); sessionId = $session; characterId = 1342177290; characterName = 'Fixture' }, + [ordered]@{ v = 1; e = 'loginCommandFailed'; t = $begin.AddSeconds(6).ToString('O'); sessionId = $session; commandIndex = 0; command = '/fixture'; error = 'allowed fixture failure' }, + [ordered]@{ v = 1; e = 'disconnected'; t = $begin.AddSeconds(7).ToString('O'); sessionId = $session; reason = 'stopped' }, + [ordered]@{ v = 1; e = 'exited'; t = $begin.AddSeconds(8).ToString('O'); sessionId = $session; code = 0; reason = 'graceful' } + ) +} + +function Write-Events([string]$Path, [object[]]$Events) { + $lines = @($Events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress }) + [IO.File]::WriteAllLines($Path, $lines, [Text.UTF8Encoding]::new($false)) +} + +function Invoke-Validator( + [string]$Status, + [string]$Profile, + [string]$Report, + [int]$ExpectedProcessId, + [bool]$ShouldPass, + [string]$SessionConfig = '') { + $arguments = [Collections.Generic.List[string]]::new() + foreach ($value in @( + '-NoProfile', '-File', $validator, + '-StatusFile', $Status, + '-Mode', 'gui', + '-ExpectedProcessId', $ExpectedProcessId.ToString( + [Globalization.CultureInfo]::InvariantCulture), + '-CredentialProfilePath', $Profile, + '-ExpectedPlugin', 'smoke', + '-AllowPluginFailure', + '-AllowLoginCommandFailure', + '-ReportPath', $Report)) { + $arguments.Add($value) + } + if (-not [string]::IsNullOrWhiteSpace($SessionConfig)) { + $arguments.Add('-SessionConfigPath') + $arguments.Add($SessionConfig) + } + $start = [Diagnostics.ProcessStartInfo]::new($pwsh) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::Start($start) + if ($null -eq $process) { throw 'Could not start status validator.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $outText = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + if (($exitCode -eq 0) -ne $ShouldPass) { + throw "Validator result mismatch (exit $exitCode). $outText $errorText" + } +} + +$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh) +$quickInfo.UseShellExecute = $false +$quickInfo.ArgumentList.Add('-NoProfile') +$quickInfo.ArgumentList.Add('-Command') +$quickInfo.ArgumentList.Add('exit 0') +$quick = [Diagnostics.Process]::Start($quickInfo) +if ($null -eq $quick) { throw 'Could not create an exited PID fixture.' } +$goneProcessId = $quick.Id +$quick.WaitForExit() +$quick.Dispose() + +$profile = Join-Path $OutputDirectory 'launcher-profiles.json' +Write-Profile $profile 'la11-positive-secret-7E477A2D' +$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl' +Write-Events $positiveStatus (New-GuiEvents) +Invoke-Validator ` + $positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') ` + $goneProcessId $true + +foreach ($reason in @('transport', 'reconnect', 'other')) { + $events = @(New-GuiEvents) + $events[7].reason = $reason + $path = Join-Path $OutputDirectory "reason-$reason.jsonl" + $report = Join-Path $OutputDirectory "reason-$reason.validation.json" + Write-Events $path $events + Invoke-Validator $path $profile $report $goneProcessId $false + $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if (-not ($result.failures -match 'disconnected reason')) { + throw "Disconnected reason '$reason' was not rejected by its exact assertion." + } +} + +$secretCases = @( + 'eventName', 'timestamp', 'sessionId', 'accountName', 'characterName', + 'enteredCharacterName', 'loadedPlugin', 'failedPlugin', 'pluginError', + 'command', 'commandError', 'disconnectedReason', 'exitReason') +foreach ($case in $secretCases) { + $secret = "la11-secret-$case-5A7D" + $caseProfile = Join-Path $OutputDirectory "secret-$case.profile.json" + Write-Profile $caseProfile $secret + $events = @(New-GuiEvents) + switch ($case) { + 'eventName' { $events[0].e = $secret } + 'timestamp' { $events[0].t = $secret } + 'sessionId' { foreach ($event in $events) { $event.sessionId = $secret } } + 'accountName' { $events[4].accountName = $secret } + 'characterName' { $events[4].characters[0].name = $secret } + 'enteredCharacterName' { $events[5].characterName = $secret } + 'loadedPlugin' { $events[1].plugin = $secret } + 'failedPlugin' { $events[2].plugin = $secret } + 'pluginError' { $events[2].error = $secret } + 'command' { $events[6].command = $secret } + 'commandError' { $events[6].error = $secret } + 'disconnectedReason' { $events[7].reason = $secret } + 'exitReason' { $events[8].reason = $secret } + } + $path = Join-Path $OutputDirectory "secret-$case.jsonl" + $report = Join-Path $OutputDirectory "secret-$case.validation.json" + Write-Events $path $events + Invoke-Validator $path $caseProfile $report $goneProcessId $false + $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if (-not ($result.failures -match 'credential value')) { + throw "Credential echo case '$case' was not rejected by recursive scanning." + } +} + +$fixtureSource = Join-Path ` + $Repository 'tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/bin/Release/net10.0' +$fixtureRoot = Join-Path $OutputDirectory 'process-fixture' +Copy-Item -LiteralPath $fixtureSource -Destination $fixtureRoot -Recurse +$sourceBase = 'AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder' +$suffix = if ($IsWindows) { '.exe' } else { '' } +$sourceHost = Join-Path $fixtureRoot "$sourceBase$suffix" +$sameNameHost = Join-Path $fixtureRoot "acdream-headless$suffix" +Copy-Item -LiteralPath $sourceHost -Destination $sameNameHost +foreach ($extension in @('.runtimeconfig.json', '.deps.json')) { + Copy-Item -LiteralPath (Join-Path $fixtureRoot "$sourceBase$extension") ` + -Destination (Join-Path $fixtureRoot "acdream-headless$extension") +} +if ($IsLinux) { + [IO.File]::SetUnixFileMode( + $sameNameHost, + [IO.File]::GetUnixFileMode($sourceHost)) +} + +$sessionConfig = Join-Path $OutputDirectory 'session.json' +[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) +$targetReady = Join-Path $OutputDirectory 'target.ready' +$targetRelease = Join-Path $OutputDirectory 'target.release' +$unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready' +$unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release' + +function Start-Fixture([string[]]$Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + return [Diagnostics.Process]::Start($start) +} + +$target = Start-Fixture @( + 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease) +$unrelated = Start-Fixture @( + 'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'), + $unrelatedReady, $unrelatedRelease) +if ($null -eq $target -or $null -eq $unrelated) { + throw 'Could not start process-correlation fixtures.' +} +try { + $deadline = [DateTime]::UtcNow.AddSeconds(10) + while ((-not (Test-Path -LiteralPath $targetReady) -or + -not (Test-Path -LiteralPath $unrelatedReady)) -and + [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 50 + } + if (-not (Test-Path -LiteralPath $targetReady) -or + -not (Test-Path -LiteralPath $unrelatedReady)) { + throw 'Process-correlation fixtures did not become ready.' + } + + $captureReport = Join-Path $OutputDirectory 'process-capture.json' + & $pwsh -NoProfile -File $capture ` + -SessionConfigPath $sessionConfig -ReportPath $captureReport + if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' } + $captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json + if ([int]$captured.processId -ne $target.Id) { + throw 'Process capture did not return the exact correlated PID.' + } + + $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json' + Invoke-Validator ` + $positiveStatus $profile $liveReport $target.Id $false $sessionConfig + $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json + if (-not ($liveResult.failures -match 'remains alive')) { + throw 'A live exact child PID was not rejected by the terminal validator.' + } + + Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline + $target.WaitForExit() + Invoke-Validator ` + $positiveStatus $profile ` + (Join-Path $OutputDirectory 'unrelated-same-name.validation.json') ` + $target.Id $true $sessionConfig +} +finally { + Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline + Set-Content -LiteralPath $unrelatedRelease -Value 'release' -NoNewline + if (-not $target.HasExited) { $target.WaitForExit() } + if (-not $unrelated.HasExited) { $unrelated.WaitForExit() } + $target.Dispose() + $unrelated.Dispose() +} + +$summary = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-gate-helper-tests' + success = $true + disconnectedReasonNegatives = 3 + credentialStringFieldNegatives = $secretCases.Count + exactPidCapture = $true + livePidRejected = $true + unrelatedSameNameIgnored = $true + platform = if ($IsWindows) { 'windows' } else { 'linux' } +} +$summary | ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM +Write-Host "Campaign LA gate helper tests: $OutputDirectory" diff --git a/tools/test-campaign-la-script-safety.ps1 b/tools/test-campaign-la-script-safety.ps1 new file mode 100644 index 00000000..7124823c --- /dev/null +++ b/tools/test-campaign-la-script-safety.ps1 @@ -0,0 +1,254 @@ +<# +.SYNOPSIS + Connection-free negative and determinism tests for Campaign LA scripts. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA script-safety tests require PowerShell 7 or newer.' +} +$Repository = [IO.Path]::GetFullPath($Repository) +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh.' +} +$null = New-Item -ItemType Directory -Path $OutputDirectory +$pwsh = [Environment]::ProcessPath +if ([string]::IsNullOrWhiteSpace($pwsh)) { + throw 'The PowerShell process path is unavailable.' +} +$preflight = Join-Path $Repository 'tools/run-campaign-la-preflight.ps1' +$fixture = Join-Path $Repository 'tools/new-campaign-la-update-fixture.ps1' +$negativeCount = 0 + +function Invoke-Expected( + [string]$Script, + [string[]]$Arguments, + [bool]$ShouldPass, + [string]$Name) { + $start = [Diagnostics.ProcessStartInfo]::new($pwsh) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.ArgumentList.Add('-NoProfile') + $start.ArgumentList.Add('-File') + $start.ArgumentList.Add($Script) + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::Start($start) + if ($null -eq $process) { throw "Could not start safety case '$Name'." } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $outText = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + if (($exitCode -eq 0) -ne $ShouldPass) { + throw "Safety case '$Name' result mismatch (exit $exitCode). $outText $errorText" + } + if (-not $ShouldPass) { $script:negativeCount++ } +} + +$allowed = Join-Path $OutputDirectory 'campaign-la-preflight-safety' +$null = New-Item -ItemType Directory -Path $allowed +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', (Join-Path $allowed 'positive'), + '-DryRun') $true 'preflight-positive' + +$existingEmpty = Join-Path $allowed 'existing-empty' +$null = New-Item -ItemType Directory -Path $existingEmpty +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', $existingEmpty, + '-DryRun') $false 'preflight-existing-empty' + +$existingNonempty = Join-Path $allowed 'existing-nonempty' +$null = New-Item -ItemType Directory -Path $existingNonempty +Set-Content -LiteralPath (Join-Path $existingNonempty 'owner') -Value 'preserve' +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', $existingNonempty, + '-DryRun') $false 'preflight-existing-nonempty' + +$payloadRootRefusal = Join-Path $OutputDirectory 'update-payloads' +$null = New-Item -ItemType Directory -Path $payloadRootRefusal +foreach ($case in @( + [pscustomobject]@{ Name = 'preflight-root'; Allowed = $Repository; Output = (Join-Path $Repository 'blocked') }, + [pscustomobject]@{ Name = 'preflight-home'; Allowed = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile); Output = (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'blocked') }, + [pscustomobject]@{ Name = 'preflight-source'; Allowed = (Join-Path $Repository 'src'); Output = (Join-Path $Repository 'src/blocked') }, + [pscustomobject]@{ Name = 'preflight-payload'; Allowed = $payloadRootRefusal; Output = (Join-Path $payloadRootRefusal 'blocked') }, + [pscustomobject]@{ Name = 'preflight-outside'; Allowed = $allowed; Output = (Join-Path $OutputDirectory 'outside') }, + [pscustomobject]@{ Name = 'preflight-allowed-root-itself'; Allowed = $allowed; Output = $allowed })) { + Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $case.Allowed, + '-OutputDirectory', $case.Output, + '-DryRun') $false $case.Name +} + +$reparseTarget = Join-Path $OutputDirectory 'campaign-la-reparse-target' +$reparseRoot = Join-Path $OutputDirectory 'campaign-la-reparse-link' +$null = New-Item -ItemType Directory -Path $reparseTarget +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $reparseRoot -Target $reparseTarget +} +else { + $null = New-Item -ItemType SymbolicLink -Path $reparseRoot -Target $reparseTarget +} +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $reparseRoot, + '-OutputDirectory', (Join-Path $reparseRoot 'blocked'), + '-DryRun') $false 'preflight-reparse-root' + +$source = Join-Path $OutputDirectory 'payload-source' +$null = New-Item -ItemType Directory -Path $source +function Fixture-DryArguments([string]$Destination, [string]$PayloadSource) { + return @( + '-OutputDirectory', $Destination, + '-ClientWinX64DirectoryA', $PayloadSource, + '-LauncherWinX64DirectoryA', $PayloadSource, + '-ClientLinuxX64DirectoryA', $PayloadSource, + '-LauncherLinuxX64DirectoryA', $PayloadSource, + '-ClientWinX64DirectoryB', $PayloadSource, + '-LauncherWinX64DirectoryB', $PayloadSource, + '-ClientLinuxX64DirectoryB', $PayloadSource, + '-LauncherLinuxX64DirectoryB', $PayloadSource, + '-DryRun') +} +Invoke-Expected $fixture (Fixture-DryArguments (Join-Path $source 'child') $source) ` + $false 'fixture-output-inside-source' +Invoke-Expected $fixture (Fixture-DryArguments $source (Join-Path $source 'child-source')) ` + $false 'fixture-source-inside-output' +Invoke-Expected $fixture (Fixture-DryArguments $source $source) ` + $false 'fixture-output-equals-source' +$nearMatch = Join-Path $OutputDirectory 'payload-source-near' +Invoke-Expected $fixture (Fixture-DryArguments $nearMatch $source) ` + $true 'fixture-near-match' + +$sourceLink = Join-Path $OutputDirectory 'payload-source-link' +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $sourceLink -Target $source +} +else { + $null = New-Item -ItemType SymbolicLink -Path $sourceLink -Target $source +} +Invoke-Expected $fixture ( + Fixture-DryArguments (Join-Path $OutputDirectory 'reparse-source-output') $sourceLink) ` + $false 'fixture-reparse-source' +$outputTarget = Join-Path $OutputDirectory 'fixture-output-target' +$outputLink = Join-Path $OutputDirectory 'fixture-output-link' +$null = New-Item -ItemType Directory -Path $outputTarget +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $outputLink -Target $outputTarget +} +else { + $null = New-Item -ItemType SymbolicLink -Path $outputLink -Target $outputTarget +} +Invoke-Expected $fixture (Fixture-DryArguments $outputLink $source) ` + $false 'fixture-reparse-output' + +function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) { + $path = Join-Path $Root $Name + $directory = Split-Path -Parent $path + $null = New-Item -ItemType Directory -Force -Path $directory + [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false)) +} +$payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads' +$payloads = [ordered]@{ + ClientWin = Join-Path $payloadRoot 'client-win' + LauncherWin = Join-Path $payloadRoot 'launcher-win' + ClientLinux = Join-Path $payloadRoot 'client-linux' + LauncherLinux = Join-Path $payloadRoot 'launcher-linux' +} +foreach ($directory in $payloads.Values) { + foreach ($entry in @( + @('nested/I.txt', 'I'), @('nested/Z.txt', 'Z'), + @('nested/ä.txt', 'a-umlaut'), @('nested/ı.txt', 'dotless-i'))) { + Write-PayloadFile $directory $entry[0] $entry[1] + } +} +Write-PayloadFile $payloads.ClientWin 'AcDream.App.exe' 'client-win-gui' +Write-PayloadFile $payloads.ClientWin 'acdream-headless.exe' 'client-win-headless' +Write-PayloadFile $payloads.LauncherWin 'acdream-launcher.exe' 'launcher-win' +Write-PayloadFile $payloads.LauncherWin 'acdream-bake.exe' 'bake-win' +Write-PayloadFile $payloads.ClientLinux 'AcDream.App' 'client-linux-gui' +Write-PayloadFile $payloads.ClientLinux 'acdream-headless' 'client-linux-headless' +Write-PayloadFile $payloads.LauncherLinux 'acdream-launcher' 'launcher-linux' +Write-PayloadFile $payloads.LauncherLinux 'acdream-bake' 'bake-linux' + +$fixtureParameters = @{ + ClientWinX64DirectoryA = $payloads.ClientWin + LauncherWinX64DirectoryA = $payloads.LauncherWin + ClientLinuxX64DirectoryA = $payloads.ClientLinux + LauncherLinuxX64DirectoryA = $payloads.LauncherLinux + ClientWinX64DirectoryB = $payloads.ClientWin + LauncherWinX64DirectoryB = $payloads.LauncherWin + ClientLinuxX64DirectoryB = $payloads.ClientLinux + LauncherLinuxX64DirectoryB = $payloads.LauncherLinux +} +$inventories = [Collections.Generic.List[object]]::new() +$originalCulture = [Globalization.CultureInfo]::CurrentCulture +$originalUiCulture = [Globalization.CultureInfo]::CurrentUICulture +try { + foreach ($cultureName in @('en-US', 'tr-TR', 'sv-SE')) { + $culture = [Globalization.CultureInfo]::GetCultureInfo($cultureName) + [Globalization.CultureInfo]::CurrentCulture = $culture + [Globalization.CultureInfo]::CurrentUICulture = $culture + $destination = Join-Path $OutputDirectory "fixture-$cultureName" + & $fixture -OutputDirectory $destination @fixtureParameters + $relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse | + Where-Object { $_.Name -ne 'fixture-report.json' } | + ForEach-Object { + [IO.Path]::GetRelativePath($destination, $_.FullName).Replace('\', '/') + }) + [Array]::Sort($relativePaths, [StringComparer]::Ordinal) + $inventory = @($relativePaths | ForEach-Object { + $path = Join-Path $destination $_.Replace('/', [IO.Path]::DirectorySeparatorChar) + "$_|$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant())" + }) + $inventories.Add($inventory) + } +} +finally { + [Globalization.CultureInfo]::CurrentCulture = $originalCulture + [Globalization.CultureInfo]::CurrentUICulture = $originalUiCulture +} +$firstInventory = [string]::Join("`n", [string[]]$inventories[0]) +foreach ($inventory in $inventories) { + if ([string]::Join("`n", [string[]]$inventory) -cne $firstInventory) { + throw 'Fixture hashes changed with the current culture.' + } +} +$digestBytes = [Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($firstInventory)) +$deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant() +$expectedCrossPlatformDigest = + '9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167' +if ($deterministicDigest -cne $expectedCrossPlatformDigest) { + throw 'Fixture artifact hashes differ from the pinned Windows/Linux contract.' +} + +$summary = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-script-safety-tests' + success = $true + negativeCases = $negativeCount + cultures = @('en-US', 'tr-TR', 'sv-SE') + fixtureArtifactSetSha256 = $deterministicDigest + crossPlatformExpectedSha256 = $expectedCrossPlatformDigest +} +$summary | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM +Write-Host "Campaign LA script safety tests: $OutputDirectory" diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 8f3b2c61..1f93340a 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -14,15 +14,15 @@ param( [Parameter(Mandatory = $true)][string]$StatusFile, [Parameter(Mandatory = $true)] [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, + [Parameter(Mandatory = $true)] + [ValidateRange(1, 2147483647)][int]$ExpectedProcessId, + [Parameter(Mandatory = $true)][string]$CredentialProfilePath, + [string]$SessionConfigPath, [string]$ExpectedSessionId, [string[]]$ExpectedPlugin = @(), [switch]$ExpectNoEnteredWorld, [switch]$AllowPluginFailure, [switch]$AllowLoginCommandFailure, - [switch]$AllowLauncherChildren, - [string[]]$ForbiddenEnvironmentVariable = @( - 'ACDREAM_TEST_PASS', - 'ACDREAM_LA_GATE_SECRET'), [string]$ReportPath, [int]$ProcessExitWaitSeconds = 5 ) @@ -35,12 +35,59 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' } +. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1') if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { $StatusFile = [IO.Path]::GetFullPath($StatusFile) } if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { throw "Status file does not exist: $StatusFile" } +if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) { + throw '-CredentialProfilePath must be absolute.' +} +$CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath) +if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) { + throw "Credential profile does not exist: $CredentialProfilePath" +} +$credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force +if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Credential profile must not be a reparse point.' +} +if ($IsLinux) { + $ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite + if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) { + throw 'Credential profile must have exact owner-only mode 0600.' + } +} +elseif ($IsWindows) { + $broadSids = @( + 'S-1-1-0', # Everyone + 'S-1-5-11', # Authenticated Users + 'S-1-5-32-545', # Builtin Users + 'S-1-5-32-546') # Guests + $acl = Get-Acl -LiteralPath $CredentialProfilePath + if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' } + foreach ($rule in $acl.Access) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { + continue + } + try { + $sid = $rule.IdentityReference.Translate( + [Security.Principal.SecurityIdentifier]).Value + } + catch { $sid = [string]$rule.IdentityReference.Value } + if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) { + throw 'Credential profile grants access to a broad Windows identity.' + } + } +} +else { throw 'Campaign LA status validation supports Windows and Linux only.' } +if (-not [string]::IsNullOrWhiteSpace($SessionConfigPath)) { + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw '-SessionConfigPath must be absolute.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) +} if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = "$StatusFile.validation.json" } @@ -65,6 +112,55 @@ $loadedPlugins = [Collections.Generic.List[string]]::new() $sessionId = $null $previousTimestamp = [DateTimeOffset]::MinValue $terminalSeen = $false +$forbiddenValues = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal) + +function Add-CredentialValues([Text.Json.JsonElement]$Element) { + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + foreach ($property in $Element.EnumerateObject()) { + if ($property.Name -imatch '^(password|secret)$' -and + $property.Value.ValueKind -eq [Text.Json.JsonValueKind]::String) { + $value = $property.Value.GetString() + if (-not [string]::IsNullOrEmpty($value)) { + $null = $forbiddenValues.Add($value) + } + } + Add-CredentialValues $property.Value + } + } + elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { Add-CredentialValues $item } + } +} + +$credentialDocument = [Text.Json.JsonDocument]::Parse( + [IO.File]::ReadAllText($CredentialProfilePath)) +try { Add-CredentialValues $credentialDocument.RootElement } +finally { $credentialDocument.Dispose() } +if ($forbiddenValues.Count -eq 0) { + throw 'Credential profile contains no non-empty password/secret value.' +} + +function Test-CredentialEcho([Text.Json.JsonElement]$Element) { + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::String) { + [string]$text = $Element.GetString() + foreach ($secret in $forbiddenValues) { + if ($text.Contains($secret, [StringComparison]::Ordinal)) { return $true } + } + return $false + } + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + foreach ($property in $Element.EnumerateObject()) { + if (Test-CredentialEcho $property.Value) { return $true } + } + } + elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { + if (Test-CredentialEcho $item) { return $true } + } + } + return $false +} function Get-Properties([Text.Json.JsonElement]$Element) { $properties = [Collections.Generic.List[object]]::new() @@ -112,13 +208,6 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { $failures.Add("line $lineNumber is empty") continue } - foreach ($variable in $ForbiddenEnvironmentVariable) { - $secret = [Environment]::GetEnvironmentVariable($variable) - if (-not [string]::IsNullOrEmpty($secret) -and - $line.Contains($secret, [StringComparison]::Ordinal)) { - $failures.Add("line $lineNumber contains the value of forbidden environment variable $variable") - } - } if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') { $failures.Add("line $lineNumber contains a credential-like JSON field") } @@ -130,6 +219,9 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { throw 'root is not an object' } + if (Test-CredentialEcho $root) { + throw 'an allowed string field contains an exact credential value' + } $properties = @(Get-Properties $root) $names = @($properties | ForEach-Object { $_.Name }) if (@($names | Sort-Object -Unique).Count -ne $names.Count) { @@ -214,7 +306,12 @@ for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { throw 'loginCommandFailed is not allowed for this row' } } - 'disconnected' { $null = Assert-String $root 'reason' } + 'disconnected' { + $reason = Assert-String $root 'reason' + if ($reason -cne 'stopped') { + throw "terminal disconnected reason is '$reason', expected 'stopped'" + } + } 'exited' { $code = Assert-Int32 $root 'code' $reason = Assert-String $root 'reason' @@ -304,16 +401,27 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) { } } -if (-not $AllowLauncherChildren) { +$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) +do { + $expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue + if ($null -eq $expectedProcess) { break } + Start-Sleep -Milliseconds 100 +} while ([DateTime]::UtcNow -lt $deadline) +if ($null -ne $expectedProcess) { + $failures.Add("expected launcher child PID $ExpectedProcessId remains alive") +} + +$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath) +if ($pathCorrelationChecked) { $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) do { - $children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue) - if ($children.Count -eq 0) { break } + $correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath) + if ($correlated.Count -eq 0) { break } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) - if ($children.Count -gt 0) { + if ($correlated.Count -gt 0) { $failures.Add( - "launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')") + "session-config-correlated launcher child PID(s) remain: $($correlated -join ',')") } } @@ -334,7 +442,11 @@ $report = [ordered]@{ eventNames = @($eventNames) loadedPluginCount = $loadedPlugins.Count terminalObserved = $terminalSeen - launcherChildrenAllowed = [bool]$AllowLauncherChildren + expectedProcessId = $ExpectedProcessId + processExited = ($null -eq $expectedProcess) + sessionConfigCorrelationChecked = $pathCorrelationChecked + credentialPermissionsValidated = $true + forbiddenCredentialValueCount = $forbiddenValues.Count failures = @($failures) validatedUtc = [DateTime]::UtcNow.ToString('O') } From 9f9c116792d1a6298ec05757d6dafc8f948fe9ad Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 01:45:15 +0200 Subject: [PATCH 055/138] fix(launcher): harden LA11 gate evidence --- .../2026-08-14-campaign-la-test-script.md | 32 ++-- tools/CampaignLaProcessCorrelation.ps1 | 171 ++++++++++++++++-- tools/capture-campaign-la-session-process.ps1 | 12 +- tools/new-campaign-la-update-fixture.ps1 | 9 +- tools/test-campaign-la-gate-helpers.ps1 | 115 +++++++++--- tools/test-campaign-la-script-safety.ps1 | 128 ++++++++++++- tools/test-campaign-la-session-status.ps1 | 136 +++++++++++--- 7 files changed, 514 insertions(+), 89 deletions(-) diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md index 0377c261..cd8f68b0 100644 --- a/docs/research/2026-08-14-campaign-la-test-script.md +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -180,8 +180,10 @@ The helper rejects nonempty output, invalid or non-monotonic versions, missing root executables (including the co-deployed Bake CLI), nonabsolute inputs, output/source overlap in either direction, and any reparse point in source or output ancestry. It enumerates normalized relative paths with ordinal ordering, -never its own output, and normalizes ZIP host metadata so Windows/Linux hashes -are identical under multiple cultures. It writes fixed-timestamp sorted ZIPs, +never its own output, and normalizes ZIP origin to Unix on both hosts so +Windows/Linux hashes are identical under multiple cultures while native Linux +extraction retains 0755 for App/Headless/Launcher/Bake and 0644 for ordinary +files. It writes fixed-timestamp sorted ZIPs, the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B selector. Both generated helpers reject a `-Root` other than their own fixture @@ -233,8 +235,10 @@ session composer, and orchestrator must all use this one exact path set. For every play/probe row, start this gate-only PID watcher immediately before clicking Refresh/Play. It correlates only the unique isolated session-config -path, records neither command line nor config contents, and must finish while -the child is still live: +path, records neither raw command line nor config contents, and must finish +while the child is still live. Its safe sidecar contains the normalized config +path, a sanitized command fingerprint, and PID plus an OS-native process-start +identity so later PID reuse cannot become a false leak: ```powershell $CapturePath = Join-Path $Evidence '-process.capture.json' @@ -262,8 +266,7 @@ $Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.js pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` -StatusFile $Status ` -Mode '' ` - -ExpectedProcessId $Capture.processId ` - -SessionConfigPath $SessionConfig ` + -ProcessCapturePath $CapturePath ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectedSessionId $Capture.sessionId ` -ReportPath (Join-Path $Evidence '-status.validation.json') @@ -273,14 +276,16 @@ Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact v1 fields **and property order**, one session id, UTC monotonic timestamps, mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login command failure, exact terminal `disconnected.reason == stopped`, credential -redaction, and that exact captured PID is gone. Optional config-path correlation -uses Windows CIM or Linux `/proc/*/cmdline`; it never globally scans a process -name, so unrelated same-name processes and Linux's 15-character names do not -affect the result. The validator verifies owner-only profile access, reads only -password/secret fields in memory, recursively checks every allowed status +redaction, and that exact captured process instance is gone. Independent exact +config-path correlation uses Windows CIM or Linux `/proc/*/cmdline`; it never +globally scans a process name, treats a different start identity on a reused PID +as a different process, and is unaffected by unrelated same-name processes or +Linux's 15-character names. The validator verifies owner-only profile access, +reads only password/secret fields in memory, recursively checks every allowed status string (including command/error text), and reports only the forbidden-value count and status hash—never credential content or a credential hash. Keep the -profile and raw `session.json`/`status.jsonl` local; never upload them. +profile, raw `session.json`/`status.jsonl`, and raw process-capture sidecar +(which contains the absolute isolated path) local; never upload them. ## 5. Serial Windows user rows A–H @@ -429,8 +434,7 @@ called. This connected row proves the actual ACE graceful-logout half. Issue pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` -Mode guiSelect ` - -ExpectedProcessId '' ` - -SessionConfigPath (Join-Path $WinCache 'launcher/sessions//session.json') ` + -ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') ` -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` -ExpectNoEnteredWorld ` -ExpectedSessionId '' ` diff --git a/tools/CampaignLaProcessCorrelation.ps1 b/tools/CampaignLaProcessCorrelation.ps1 index 00718f13..25864a2d 100644 --- a/tools/CampaignLaProcessCorrelation.ps1 +++ b/tools/CampaignLaProcessCorrelation.ps1 @@ -1,31 +1,127 @@ Set-StrictMode -Version Latest +function Get-CampaignLaSha256([string]$Text) { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + return [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() +} + +function Get-CampaignLaCommandLineFingerprint( + [string]$ExecutablePath, + [string]$ConfigArgument, + [string]$SessionConfigPath) { + if (-not [IO.Path]::IsPathFullyQualified($ExecutablePath) -or + -not [IO.Path]::IsPathFullyQualified($SessionConfigPath) -or + $ConfigArgument -cnotin @('--config', '--session-config')) { + throw 'Cannot fingerprint an incomplete launcher-child command line.' + } + $executable = [IO.Path]::GetFullPath($ExecutablePath) + $config = [IO.Path]::GetFullPath($SessionConfigPath) + # Only the executable and the recognized config argument are retained in + # this projection. Launcher credentials use stdin; unrelated argv is + # deliberately excluded so an accidental secret can never enter evidence. + return Get-CampaignLaSha256( + "campaign-la-child-command-v1`n$executable`n$ConfigArgument`n$config") +} + +function Get-CampaignLaLinuxProcessIdentity( + [string]$ProcessDirectory, + [string]$BootId) { + $stat = [IO.File]::ReadAllText((Join-Path $ProcessDirectory 'stat')) + $commandEnd = $stat.LastIndexOf(')') + if ($commandEnd -lt 2 -or $commandEnd + 2 -ge $stat.Length) { + throw 'Linux process stat record is malformed.' + } + # The tail begins at field 3 (state); field 22 (starttime) is index 19. + $tail = @($stat.Substring($commandEnd + 2).Split( + ' ', + [StringSplitOptions]::RemoveEmptyEntries)) + if ($tail.Count -le 19) { throw 'Linux process stat record has no starttime.' } + $startTicks = [uint64]::Parse( + $tail[19], + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture) + return "linux-proc-start-v1:$BootId`:$startTicks" +} + +function Get-CampaignLaProcessInstanceIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateRange(1, 2147483647)][int]$ProcessId) + + if ($IsWindows) { + $candidate = Get-CimInstance Win32_Process ` + -Filter "ProcessId=$ProcessId" -ErrorAction Stop + if ($null -eq $candidate) { return $null } + if ($null -eq $candidate.CreationDate) { + throw "Windows process $ProcessId has no creation time." + } + return "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)" + } + if ($IsLinux) { + $directory = "/proc/$ProcessId" + if (-not [IO.Directory]::Exists($directory)) { return $null } + try { + $bootId = [IO.File]::ReadAllText( + '/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant() + if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + throw 'Linux boot id is malformed.' + } + return Get-CampaignLaLinuxProcessIdentity $directory $bootId + } + catch [IO.FileNotFoundException] { return $null } + catch [IO.DirectoryNotFoundException] { return $null } + catch [IO.IOException] { + if (-not [IO.Directory]::Exists($directory)) { return $null } + throw + } + } + throw 'Campaign LA process identity supports Windows and Linux only.' +} + function Get-CampaignLaSessionProcessCorrelations { [CmdletBinding()] param() - $matches = [Collections.Generic.List[object]]::new() + $correlations = [Collections.Generic.List[object]]::new() if ($IsWindows) { - $pattern = '(?i)(?:^|\s)(?:--config|--session-config)\s+(?:"([^"]+)"|(\S+))' + $pattern = '(?i)(?:^|\s)(--config|--session-config)\s+(?:"([^"]+)"|(\S+))' foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { $commandLine = [string]$candidate.CommandLine - if ([string]::IsNullOrWhiteSpace($commandLine)) { continue } + $executablePath = [string]$candidate.ExecutablePath + if ([string]::IsNullOrWhiteSpace($commandLine) -or + -not [IO.Path]::IsPathFullyQualified($executablePath) -or + $null -eq $candidate.CreationDate) { + continue + } + $identity = "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)" foreach ($match in [Text.RegularExpressions.Regex]::Matches( $commandLine, $pattern)) { - $value = if ($match.Groups[1].Success) { - $match.Groups[1].Value - } else { $match.Groups[2].Value } + $argument = $match.Groups[1].Value.ToLowerInvariant() + $value = if ($match.Groups[2].Success) { + $match.Groups[2].Value + } else { $match.Groups[3].Value } if ([IO.Path]::IsPathFullyQualified($value)) { - $matches.Add([pscustomobject]@{ + $configPath = [IO.Path]::GetFullPath($value) + $correlations.Add([pscustomobject]@{ ProcessId = [int]$candidate.ProcessId - SessionConfigPath = [IO.Path]::GetFullPath($value) + ProcessInstanceIdentity = $identity + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $executablePath $argument $configPath }) } } } } elseif ($IsLinux) { + $bootId = [IO.File]::ReadAllText('/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant() + if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + throw 'Linux boot id is malformed.' + } foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) { $leaf = [IO.Path]::GetFileName($directory) $processId = 0 @@ -37,24 +133,34 @@ function Get-CampaignLaSessionProcessCorrelations { continue } try { + $identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId $bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline')) if ($bytes.Length -eq 0) { continue } $arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split( [char]0, [StringSplitOptions]::RemoveEmptyEntries)) + $identityAfter = Get-CampaignLaLinuxProcessIdentity $directory $bootId + if ($identityBefore -cne $identityAfter -or $arguments.Count -eq 0 -or + -not [IO.Path]::IsPathFullyQualified($arguments[0])) { + continue + } for ($index = 0; $index + 1 -lt $arguments.Count; $index++) { if ($arguments[$index] -cin @('--config', '--session-config') -and [IO.Path]::IsPathFullyQualified($arguments[$index + 1])) { - $matches.Add([pscustomobject]@{ + $configPath = [IO.Path]::GetFullPath($arguments[$index + 1]) + $correlations.Add([pscustomobject]@{ ProcessId = $processId - SessionConfigPath = [IO.Path]::GetFullPath( - $arguments[$index + 1]) + ProcessInstanceIdentity = $identityBefore + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $arguments[0] $arguments[$index] $configPath }) } } } catch [IO.IOException] { - # A process may exit between /proc enumeration and cmdline read. + # A process may exit between /proc enumeration and either read. } catch [UnauthorizedAccessException] { # Other-user processes cannot be the owner-readable gate child. @@ -65,7 +171,7 @@ function Get-CampaignLaSessionProcessCorrelations { throw 'Campaign LA process correlation supports Windows and Linux only.' } - return @($matches) + return @($correlations) } function Get-CampaignLaCorrelatedProcessIds { @@ -79,15 +185,48 @@ function Get-CampaignLaCorrelatedProcessIds { $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } - $matches = [Collections.Generic.HashSet[int]]::new() + $processIds = [Collections.Generic.HashSet[int]]::new() foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { if ([string]::Equals( $candidate.SessionConfigPath, $SessionConfigPath, $comparison)) { - $null = $matches.Add([int]$candidate.ProcessId) + $null = $processIds.Add([int]$candidate.ProcessId) } } - return @($matches | Sort-Object) + return @($processIds | Sort-Object) +} + +function Test-CampaignLaCapturedProcessState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][int]$ProcessId, + [Parameter(Mandatory = $true)][string]$ProcessInstanceIdentity, + [Parameter(Mandatory = $true)][string]$SessionConfigPath, + [AllowNull()][string]$CurrentProcessInstanceIdentity, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()][object[]]$Correlations) + + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $sameInstanceAlive = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and + $CurrentProcessInstanceIdentity -ceq $ProcessInstanceIdentity + $exactConfigPathAlive = $false + $pidReused = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and + $CurrentProcessInstanceIdentity -cne $ProcessInstanceIdentity + foreach ($candidate in $Correlations) { + if ([string]::Equals( + [string]$candidate.SessionConfigPath, + $SessionConfigPath, + $comparison)) { + $exactConfigPathAlive = $true + } + } + return [pscustomobject]@{ + SameInstanceAlive = $sameInstanceAlive + ExactConfigPathAlive = $exactConfigPathAlive + PidReused = $pidReused + } } diff --git a/tools/capture-campaign-la-session-process.ps1 b/tools/capture-campaign-la-session-process.ps1 index 448660d4..4b0fd74a 100644 --- a/tools/capture-campaign-la-session-process.ps1 +++ b/tools/capture-campaign-la-session-process.ps1 @@ -90,18 +90,26 @@ if ($correlations.Count -ne 1) { throw 'No live process uses the isolated session config.' } $SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath) +$processIdentity = [string]$correlations[0].ProcessInstanceIdentity +$commandFingerprint = [string]$correlations[0].CommandLineFingerprintSha256 +if ($processIdentity -notmatch '^(windows-creation-v1:[0-9]{15,19}|linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+)$' -or + $commandFingerprint -notmatch '^[0-9a-f]{64}$') { + throw 'The correlated process instance evidence is malformed.' +} $directory = Split-Path -Parent $ReportPath if (-not [string]::IsNullOrEmpty($directory)) { $null = New-Item -ItemType Directory -Force -Path $directory } $report = [ordered]@{ - schemaVersion = 1 + schemaVersion = 2 kind = 'campaign-la-session-process-capture' processId = [int]$correlations[0].ProcessId + processInstanceIdentity = $processIdentity sessionId = [IO.Path]::GetFileName( [IO.Path]::GetDirectoryName($SessionConfigPath)) - sessionConfigFile = [IO.Path]::GetFileName($SessionConfigPath) + sessionConfigPath = $SessionConfigPath + commandLineFingerprintSha256 = $commandFingerprint capturedUtc = [DateTime]::UtcNow.ToString('O') } $report | ConvertTo-Json -Depth 3 | diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 index 2e8723f8..d6609f47 100644 --- a/tools/new-campaign-la-update-fixture.ps1 +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -208,10 +208,11 @@ function Set-DeterministicZipHostPlatform([string]$Path) { (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { throw "Generated ZIP central-directory entry is invalid: $Path" } - # ZipArchive intentionally stamps the creating host (FAT on Windows, - # Unix on Linux) in the upper byte of "version made by". Normalize it - # to FAT; permissions are already explicit in ExternalAttributes. - $bytes[[int]$cursor + 5] = 0 + # ZipArchive stamps the creating host (FAT on Windows, Unix on Linux) + # in the upper byte of "version made by". Normalize to Unix so native + # extraction honors the explicit regular-file type and 0755/0644 mode + # bits already stored in ExternalAttributes. + $bytes[[int]$cursor + 5] = 3 $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) diff --git a/tools/test-campaign-la-gate-helpers.ps1 b/tools/test-campaign-la-gate-helpers.ps1 index 11756555..f1751988 100644 --- a/tools/test-campaign-la-gate-helpers.ps1 +++ b/tools/test-campaign-la-gate-helpers.ps1 @@ -28,6 +28,7 @@ if ([string]::IsNullOrWhiteSpace($pwsh)) { } $validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1' $capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1' +. (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.ps1') function Write-Profile([string]$Path, [string]$Secret) { $document = [ordered]@{ @@ -86,16 +87,14 @@ function Invoke-Validator( [string]$Status, [string]$Profile, [string]$Report, - [int]$ExpectedProcessId, - [bool]$ShouldPass, - [string]$SessionConfig = '') { + [string]$ProcessCapture, + [bool]$ShouldPass) { $arguments = [Collections.Generic.List[string]]::new() foreach ($value in @( '-NoProfile', '-File', $validator, '-StatusFile', $Status, '-Mode', 'gui', - '-ExpectedProcessId', $ExpectedProcessId.ToString( - [Globalization.CultureInfo]::InvariantCulture), + '-ProcessCapturePath', $ProcessCapture, '-CredentialProfilePath', $Profile, '-ExpectedPlugin', 'smoke', '-AllowPluginFailure', @@ -103,10 +102,6 @@ function Invoke-Validator( '-ReportPath', $Report)) { $arguments.Add($value) } - if (-not [string]::IsNullOrWhiteSpace($SessionConfig)) { - $arguments.Add('-SessionConfigPath') - $arguments.Add($SessionConfig) - } $start = [Diagnostics.ProcessStartInfo]::new($pwsh) $start.UseShellExecute = $false $start.CreateNoWindow = $true @@ -127,6 +122,30 @@ function Invoke-Validator( } } +function Write-ProcessCapture( + [string]$Path, + [int]$ProcessId, + [string]$ProcessInstanceIdentity, + [string]$SessionConfigPath, + [string]$CommandFingerprint = ('a' * 64)) { + $sessionId = [IO.Path]::GetFileName( + [IO.Path]::GetDirectoryName($SessionConfigPath)) + $document = [ordered]@{ + schemaVersion = 2 + kind = 'campaign-la-session-process-capture' + processId = $ProcessId + processInstanceIdentity = $ProcessInstanceIdentity + sessionId = $sessionId + sessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + commandLineFingerprintSha256 = $CommandFingerprint + capturedUtc = [DateTime]::UtcNow.ToString('O') + } + [IO.File]::WriteAllText( + $Path, + ($document | ConvertTo-Json -Depth 4), + [Text.UTF8Encoding]::new($false)) +} + $quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh) $quickInfo.UseShellExecute = $false $quickInfo.ArgumentList.Add('-NoProfile') @@ -138,13 +157,33 @@ $goneProcessId = $quick.Id $quick.WaitForExit() $quick.Dispose() +$sessionRoot = Join-Path $OutputDirectory 'fixture-session' +$null = New-Item -ItemType Directory -Path $sessionRoot +$sessionConfig = Join-Path $sessionRoot 'session.json' +[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) +$syntheticIdentity = if ($IsWindows) { + 'windows-creation-v1:638000000000000000' +} else { 'linux-proc-start-v1:00000000-0000-0000-0000-000000000001:1' } +$goneCapture = Join-Path $OutputDirectory 'gone-process.capture.json' +Write-ProcessCapture ` + $goneCapture $goneProcessId $syntheticIdentity $sessionConfig + $profile = Join-Path $OutputDirectory 'launcher-profiles.json' Write-Profile $profile 'la11-positive-secret-7E477A2D' $positiveStatus = Join-Path $OutputDirectory 'positive.jsonl' Write-Events $positiveStatus (New-GuiEvents) Invoke-Validator ` $positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') ` - $goneProcessId $true + $goneCapture $true + +$malformedCapture = Join-Path $OutputDirectory 'malformed-process.capture.json' +[IO.File]::WriteAllText( + $malformedCapture, + '{"schemaVersion":2}', + [Text.UTF8Encoding]::new($false)) +Invoke-Validator ` + $positiveStatus $profile (Join-Path $OutputDirectory 'malformed.validation.json') ` + $malformedCapture $false foreach ($reason in @('transport', 'reconnect', 'other')) { $events = @(New-GuiEvents) @@ -152,7 +191,7 @@ foreach ($reason in @('transport', 'reconnect', 'other')) { $path = Join-Path $OutputDirectory "reason-$reason.jsonl" $report = Join-Path $OutputDirectory "reason-$reason.validation.json" Write-Events $path $events - Invoke-Validator $path $profile $report $goneProcessId $false + Invoke-Validator $path $profile $report $goneCapture $false $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json if (-not ($result.failures -match 'disconnected reason')) { throw "Disconnected reason '$reason' was not rejected by its exact assertion." @@ -186,7 +225,7 @@ foreach ($case in $secretCases) { $path = Join-Path $OutputDirectory "secret-$case.jsonl" $report = Join-Path $OutputDirectory "secret-$case.validation.json" Write-Events $path $events - Invoke-Validator $path $caseProfile $report $goneProcessId $false + Invoke-Validator $path $caseProfile $report $goneCapture $false $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json if (-not ($result.failures -match 'credential value')) { throw "Credential echo case '$case' was not rejected by recursive scanning." @@ -212,12 +251,14 @@ if ($IsLinux) { [IO.File]::GetUnixFileMode($sourceHost)) } -$sessionConfig = Join-Path $OutputDirectory 'session.json' -[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) $targetReady = Join-Path $OutputDirectory 'target.ready' $targetRelease = Join-Path $OutputDirectory 'target.release' $unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready' $unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release' +$unrelatedSessionRoot = Join-Path $OutputDirectory 'unrelated-session' +$null = New-Item -ItemType Directory -Path $unrelatedSessionRoot +$unrelatedConfig = Join-Path $unrelatedSessionRoot 'session.json' +[IO.File]::WriteAllText($unrelatedConfig, '{}', [Text.UTF8Encoding]::new($false)) function Start-Fixture([string[]]$Arguments) { $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost) @@ -230,7 +271,7 @@ function Start-Fixture([string[]]$Arguments) { $target = Start-Fixture @( 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease) $unrelated = Start-Fixture @( - 'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'), + 'hold-campaign-la-process', '--config', $unrelatedConfig, $unrelatedReady, $unrelatedRelease) if ($null -eq $target -or $null -eq $unrelated) { throw 'Could not start process-correlation fixtures.' @@ -252,16 +293,19 @@ try { -SessionConfigPath $sessionConfig -ReportPath $captureReport if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' } $captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json - if ([int]$captured.processId -ne $target.Id) { - throw 'Process capture did not return the exact correlated PID.' + if ([int]$captured.processId -ne $target.Id -or + [string]$captured.sessionConfigPath -cne $sessionConfig -or + [string]$captured.commandLineFingerprintSha256 -cnotmatch '^[0-9a-f]{64}$') { + throw 'Process capture did not return exact sanitized instance evidence.' } $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json' Invoke-Validator ` - $positiveStatus $profile $liveReport $target.Id $false $sessionConfig + $positiveStatus $profile $liveReport $captureReport $false $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json - if (-not ($liveResult.failures -match 'remains alive')) { - throw 'A live exact child PID was not rejected by the terminal validator.' + if (-not ($liveResult.failures -match 'process instance.*remains alive') -or + $liveResult.capturedProcessInstanceExited) { + throw 'A live exact child instance was not rejected by the terminal validator.' } Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline @@ -269,7 +313,31 @@ try { Invoke-Validator ` $positiveStatus $profile ` (Join-Path $OutputDirectory 'unrelated-same-name.validation.json') ` - $target.Id $true $sessionConfig + $captureReport $true + + $reusedCapture = Join-Path $OutputDirectory 'reused-pid.capture.json' + $capturedIdentity = [string]$captured.processInstanceIdentity + $identitySeparator = $capturedIdentity.LastIndexOf(':') + $capturedStartValue = [uint64]::Parse( + $capturedIdentity.Substring($identitySeparator + 1), + [Globalization.CultureInfo]::InvariantCulture) + $reusedPriorIdentity = $capturedIdentity.Substring(0, $identitySeparator + 1) ` + + ($capturedStartValue + 1).ToString( + [Globalization.CultureInfo]::InvariantCulture) + Write-ProcessCapture ` + $reusedCapture ` + $unrelated.Id ` + $reusedPriorIdentity ` + $sessionConfig ` + ([string]$captured.commandLineFingerprintSha256) + $reusedReport = Join-Path $OutputDirectory 'reused-pid.validation.json' + Invoke-Validator $positiveStatus $profile $reusedReport $reusedCapture $true + $reusedResult = Get-Content -LiteralPath $reusedReport -Raw | ConvertFrom-Json + if (-not $reusedResult.capturedPidReused -or + -not $reusedResult.capturedProcessInstanceExited -or + -not $reusedResult.sessionConfigProcessExited) { + throw 'A reused PID was not distinguished from the exited captured instance.' + } } finally { Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline @@ -287,7 +355,10 @@ $summary = [ordered]@{ disconnectedReasonNegatives = 3 credentialStringFieldNegatives = $secretCases.Count exactPidCapture = $true - livePidRejected = $true + stableProcessInstanceCapture = $true + liveProcessInstanceRejected = $true + malformedProcessCaptureRejected = $true + injectedPidReuseIgnored = $true unrelatedSameNameIgnored = $true platform = if ($IsWindows) { 'windows' } else { 'linux' } } diff --git a/tools/test-campaign-la-script-safety.ps1 b/tools/test-campaign-la-script-safety.ps1 index 7124823c..d7f93c90 100644 --- a/tools/test-campaign-la-script-safety.ps1 +++ b/tools/test-campaign-la-script-safety.ps1 @@ -165,6 +165,84 @@ function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) { $null = New-Item -ItemType Directory -Force -Path $directory [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false)) } + +function Get-ZipUInt16([byte[]]$Bytes, [int]$Offset) { + return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8) +} +function Get-ZipUInt32([byte[]]$Bytes, [int]$Offset) { + return [uint32]([uint32]$Bytes[$Offset] -bor + ([uint32]$Bytes[$Offset + 1] -shl 8) -bor + ([uint32]$Bytes[$Offset + 2] -shl 16) -bor + ([uint32]$Bytes[$Offset + 3] -shl 24)) +} +function Test-ZipExecutableName([string]$Name) { + return $Name -cin @( + 'AcDream.App', 'acdream-headless', 'acdream-launcher', 'acdream-bake') +} +function Assert-ZipUnixMetadata([string]$Path) { + [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) + $eocd = $bytes.Length - 22 + if ($eocd -lt 0 -or (Get-ZipUInt32 $bytes $eocd) -ne 0x06054b50 -or + (Get-ZipUInt16 $bytes ($eocd + 20)) -ne 0) { + throw "Fixture ZIP end record is invalid: $Path" + } + $entryCount = Get-ZipUInt16 $bytes ($eocd + 10) + $centralSize = Get-ZipUInt32 $bytes ($eocd + 12) + [uint64]$cursor = Get-ZipUInt32 $bytes ($eocd + 16) + $centralEnd = $cursor + $centralSize + if ($centralEnd -ne $eocd) { throw "Fixture ZIP central bounds are invalid: $Path" } + $rawModes = @{} + for ($index = 0; $index -lt $entryCount; $index++) { + if ($cursor + 46 -gt $centralEnd -or + (Get-ZipUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { + throw "Fixture ZIP central entry is invalid: $Path" + } + if ($bytes[[int]$cursor + 5] -ne 3) { + throw "Fixture ZIP entry origin is not Unix: $Path" + } + $nameLength = Get-ZipUInt16 $bytes ([int]$cursor + 28) + $extraLength = Get-ZipUInt16 $bytes ([int]$cursor + 30) + $commentLength = Get-ZipUInt16 $bytes ([int]$cursor + 32) + $name = [Text.Encoding]::UTF8.GetString( + $bytes, + [int]$cursor + 46, + $nameLength) + $expectedMode = if (Test-ZipExecutableName $name) { 0x81ED } else { 0x81A4 } + $external = Get-ZipUInt32 $bytes ([int]$cursor + 38) + $expectedExternal = [uint32](([uint64]$expectedMode) -shl 16) + if ($external -ne $expectedExternal) { + throw "Fixture ZIP entry '$name' has wrong raw type/mode bits." + } + $rawModes[$name] = $expectedMode + $cursor += 46 + $nameLength + $extraLength + $commentLength + } + if ($cursor -ne $centralEnd) { throw "Fixture ZIP central length is invalid: $Path" } + + Add-Type -AssemblyName System.IO.Compression + $stream = [IO.File]::OpenRead($Path) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Read, + $false, + [Text.Encoding]::UTF8) + try { + if ($archive.Entries.Count -ne $rawModes.Count) { + throw "Fixture ZIP entry count changed through ZipArchive: $Path" + } + foreach ($entry in $archive.Entries) { + $mode = ($entry.ExternalAttributes -shr 16) -band 0xffff + if (-not $rawModes.ContainsKey($entry.FullName) -or + $mode -ne $rawModes[$entry.FullName]) { + throw "ZipArchive reports wrong type/mode for '$($entry.FullName)'." + } + } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } +} + $payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads' $payloads = [ordered]@{ ClientWin = Join-Path $payloadRoot 'client-win' @@ -208,6 +286,9 @@ try { [Globalization.CultureInfo]::CurrentUICulture = $culture $destination = Join-Path $OutputDirectory "fixture-$cultureName" & $fixture -OutputDirectory $destination @fixtureParameters + foreach ($zip in @(Get-ChildItem -LiteralPath $destination -Filter '*.zip' -File -Recurse)) { + Assert-ZipUnixMetadata $zip.FullName + } $relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse | Where-Object { $_.Name -ne 'fixture-report.json' } | ForEach-Object { @@ -235,9 +316,49 @@ $digestBytes = [Security.Cryptography.SHA256]::HashData( [Text.Encoding]::UTF8.GetBytes($firstInventory)) $deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant() $expectedCrossPlatformDigest = - '9c77b7204dd19e77fad62e572304d52e810afe2d0821c2ec57a692d27a0cc167' + 'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76' if ($deterministicDigest -cne $expectedCrossPlatformDigest) { - throw 'Fixture artifact hashes differ from the pinned Windows/Linux contract.' + throw "Fixture artifact hashes differ from the pinned Windows/Linux contract: actual $deterministicDigest." +} + +$nativeExtractionModesValidated = $false +if ($IsLinux) { + $unzip = @(Get-Command unzip -CommandType Application -ErrorAction Stop)[0].Source + $extractClient = Join-Path $OutputDirectory 'native-extract-client' + $extractLauncher = Join-Path $OutputDirectory 'native-extract-launcher' + $null = New-Item -ItemType Directory -Path $extractClient + $null = New-Item -ItemType Directory -Path $extractLauncher + & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/client-linux-x64.zip') ` + -d $extractClient + if ($LASTEXITCODE -ne 0) { throw 'Native client ZIP extraction failed.' } + & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/launcher-linux-x64.zip') ` + -d $extractLauncher + if ($LASTEXITCODE -ne 0) { throw 'Native launcher ZIP extraction failed.' } + $mode755 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor + [IO.UnixFileMode]::UserExecute -bor [IO.UnixFileMode]::GroupRead -bor + [IO.UnixFileMode]::GroupExecute -bor [IO.UnixFileMode]::OtherRead -bor + [IO.UnixFileMode]::OtherExecute + $mode644 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor + [IO.UnixFileMode]::GroupRead -bor [IO.UnixFileMode]::OtherRead + foreach ($path in @( + (Join-Path $extractClient 'AcDream.App'), + (Join-Path $extractClient 'acdream-headless'), + (Join-Path $extractLauncher 'acdream-launcher'), + (Join-Path $extractLauncher 'acdream-bake'))) { + if ([IO.File]::GetUnixFileMode($path) -ne $mode755) { + throw "Native extraction did not retain mode 0755: $path" + } + } + foreach ($path in @( + (Join-Path $extractClient 'nested/I.txt'), + (Join-Path $extractClient 'campaign-la-fixture-release.txt'), + (Join-Path $extractLauncher 'nested/Z.txt'), + (Join-Path $extractLauncher 'campaign-la-fixture-release.txt'))) { + if ([IO.File]::GetUnixFileMode($path) -ne $mode644) { + throw "Native extraction did not retain mode 0644: $path" + } + } + $nativeExtractionModesValidated = $true } $summary = [ordered]@{ @@ -248,6 +369,9 @@ $summary = [ordered]@{ cultures = @('en-US', 'tr-TR', 'sv-SE') fixtureArtifactSetSha256 = $deterministicDigest crossPlatformExpectedSha256 = $expectedCrossPlatformDigest + zipOrigin = 'unix' + zipModesValidated = $true + nativeExtractionModesValidated = $nativeExtractionModesValidated } $summary | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 index 1f93340a..ce89fcd2 100644 --- a/tools/test-campaign-la-session-status.ps1 +++ b/tools/test-campaign-la-session-status.ps1 @@ -14,10 +14,8 @@ param( [Parameter(Mandatory = $true)][string]$StatusFile, [Parameter(Mandatory = $true)] [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, - [Parameter(Mandatory = $true)] - [ValidateRange(1, 2147483647)][int]$ExpectedProcessId, + [Parameter(Mandatory = $true)][string]$ProcessCapturePath, [Parameter(Mandatory = $true)][string]$CredentialProfilePath, - [string]$SessionConfigPath, [string]$ExpectedSessionId, [string[]]$ExpectedPlugin = @(), [switch]$ExpectNoEnteredWorld, @@ -42,6 +40,87 @@ if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { throw "Status file does not exist: $StatusFile" } +if (-not [IO.Path]::IsPathFullyQualified($ProcessCapturePath)) { + throw '-ProcessCapturePath must be absolute.' +} +$ProcessCapturePath = [IO.Path]::GetFullPath($ProcessCapturePath) +if (-not (Test-Path -LiteralPath $ProcessCapturePath -PathType Leaf)) { + throw "Process capture does not exist: $ProcessCapturePath" +} +$captureItem = Get-Item -LiteralPath $ProcessCapturePath -Force +if (($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Process capture must not be a reparse point.' +} +$captureDocument = [Text.Json.JsonDocument]::Parse( + [IO.File]::ReadAllText($ProcessCapturePath)) +try { + $captureRoot = $captureDocument.RootElement + if ($captureRoot.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'Process capture root must be an object.' + } + $captureNames = @($captureRoot.EnumerateObject() | ForEach-Object { $_.Name }) + $expectedCaptureNames = @( + 'schemaVersion', 'kind', 'processId', 'processInstanceIdentity', + 'sessionId', 'sessionConfigPath', 'commandLineFingerprintSha256', + 'capturedUtc') + if ([string]::Join("`n", $captureNames) -cne + [string]::Join("`n", $expectedCaptureNames)) { + throw 'Process capture fields/order do not match schema v2.' + } + if ($captureRoot.GetProperty('schemaVersion').GetInt32() -ne 2 -or + $captureRoot.GetProperty('kind').GetString() -cne + 'campaign-la-session-process-capture') { + throw 'Process capture schema/kind is invalid.' + } + $capturedProcessId = $captureRoot.GetProperty('processId').GetInt32() + if ($capturedProcessId -le 0) { throw 'Process capture PID is invalid.' } + $capturedProcessIdentity = $captureRoot.GetProperty( + 'processInstanceIdentity').GetString() + $expectedIdentityPattern = if ($IsWindows) { + '^windows-creation-v1:[0-9]{15,19}$' + } else { '^linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+$' } + if ($capturedProcessIdentity -notmatch $expectedIdentityPattern) { + throw 'Process capture instance identity is invalid for this platform.' + } + $capturedSessionId = $captureRoot.GetProperty('sessionId').GetString() + if ([string]::IsNullOrWhiteSpace($capturedSessionId) -or + $capturedSessionId.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) { + throw 'Process capture session id is invalid.' + } + $capturedSessionConfigPath = $captureRoot.GetProperty( + 'sessionConfigPath').GetString() + if (-not [IO.Path]::IsPathFullyQualified($capturedSessionConfigPath)) { + throw 'Process capture session-config path is not absolute.' + } + $normalizedCapturedConfigPath = [IO.Path]::GetFullPath( + $capturedSessionConfigPath) + if ($capturedSessionConfigPath -cne $normalizedCapturedConfigPath -or + [IO.Path]::GetFileName($capturedSessionConfigPath) -cne 'session.json' -or + [IO.Path]::GetFileName([IO.Path]::GetDirectoryName( + $capturedSessionConfigPath)) -cne $capturedSessionId) { + throw 'Process capture session-config path is not the exact normalized session path.' + } + $capturedCommandFingerprint = $captureRoot.GetProperty( + 'commandLineFingerprintSha256').GetString() + if ($capturedCommandFingerprint -cnotmatch '^[0-9a-f]{64}$') { + throw 'Process capture command-line fingerprint is invalid.' + } + $capturedUtcText = $captureRoot.GetProperty('capturedUtc').GetString() + $capturedUtc = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $capturedUtcText, + 'O', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$capturedUtc) -or $capturedUtc.Offset -ne [TimeSpan]::Zero) { + throw 'Process capture timestamp is not exact UTC round-trip form.' + } +} +finally { $captureDocument.Dispose() } +if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and + $ExpectedSessionId -cne $capturedSessionId) { + throw 'Process capture session id does not match -ExpectedSessionId.' +} if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) { throw '-CredentialProfilePath must be absolute.' } @@ -82,12 +161,6 @@ elseif ($IsWindows) { } } else { throw 'Campaign LA status validation supports Windows and Linux only.' } -if (-not [string]::IsNullOrWhiteSpace($SessionConfigPath)) { - if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { - throw '-SessionConfigPath must be absolute.' - } - $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) -} if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = "$StatusFile.validation.json" } @@ -402,27 +475,29 @@ for ($index = 0; $index -lt $eventNames.Count; $index++) { } $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) +$capturedState = $null do { - $expectedProcess = Get-Process -Id $ExpectedProcessId -ErrorAction SilentlyContinue - if ($null -eq $expectedProcess) { break } + $currentProcessIdentity = Get-CampaignLaProcessInstanceIdentity ` + -ProcessId $capturedProcessId + $correlations = @(Get-CampaignLaSessionProcessCorrelations) + $capturedState = Test-CampaignLaCapturedProcessState ` + -ProcessId $capturedProcessId ` + -ProcessInstanceIdentity $capturedProcessIdentity ` + -SessionConfigPath $capturedSessionConfigPath ` + -CurrentProcessInstanceIdentity $currentProcessIdentity ` + -Correlations $correlations + if (-not $capturedState.SameInstanceAlive -and + -not $capturedState.ExactConfigPathAlive) { + break + } Start-Sleep -Milliseconds 100 } while ([DateTime]::UtcNow -lt $deadline) -if ($null -ne $expectedProcess) { - $failures.Add("expected launcher child PID $ExpectedProcessId remains alive") +if ($capturedState.SameInstanceAlive) { + $failures.Add( + "captured launcher child process instance PID $capturedProcessId remains alive") } - -$pathCorrelationChecked = -not [string]::IsNullOrWhiteSpace($SessionConfigPath) -if ($pathCorrelationChecked) { - $deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) - do { - $correlated = @(Get-CampaignLaCorrelatedProcessIds $SessionConfigPath) - if ($correlated.Count -eq 0) { break } - Start-Sleep -Milliseconds 100 - } while ([DateTime]::UtcNow -lt $deadline) - if ($correlated.Count -gt 0) { - $failures.Add( - "session-config-correlated launcher child PID(s) remain: $($correlated -join ',')") - } +if ($capturedState.ExactConfigPathAlive) { + $failures.Add('a launcher child remains correlated to the exact session-config path') } $reportDirectory = Split-Path -Parent $ReportPath @@ -442,9 +517,12 @@ $report = [ordered]@{ eventNames = @($eventNames) loadedPluginCount = $loadedPlugins.Count terminalObserved = $terminalSeen - expectedProcessId = $ExpectedProcessId - processExited = ($null -eq $expectedProcess) - sessionConfigCorrelationChecked = $pathCorrelationChecked + capturedProcessId = $capturedProcessId + capturedProcessInstanceExited = (-not $capturedState.SameInstanceAlive) + capturedPidReused = [bool]$capturedState.PidReused + sessionConfigCorrelationChecked = $true + sessionConfigProcessExited = (-not $capturedState.ExactConfigPathAlive) + processCaptureSha256 = (Get-FileHash -LiteralPath $ProcessCapturePath -Algorithm SHA256).Hash.ToLowerInvariant() credentialPermissionsValidated = $true forbiddenCredentialValueCount = $forbiddenValues.Count failures = @($failures) From a22f54117b05b263a58a32c57ec85847eec1e12b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 02:08:18 +0200 Subject: [PATCH 056/138] docs(launcher): record Campaign LA11 automated closeout --- docs/plans/2026-08-14-launcher-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 059db843..d4592726 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. | | LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. | | LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0–LA10 gate: 13,972+5 skip. | -| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`; merge pending | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. | +| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`, merge `d39f3098` | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. | From c25545e8d6d61646199e1effb311e19f616e920d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 02:16:41 +0200 Subject: [PATCH 057/138] docs(launcher): prepare Campaign LA user gate --- CLAUDE.md | 8 +++++--- docs/plans/2026-04-11-roadmap.md | 11 +++++++---- docs/plans/2026-08-14-launcher-campaign.md | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c361d6bc..9baa7773 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,14 +245,16 @@ NO 3D preview (chargen-only machinery); UI Studio no longer exists (deleted at Campaign V — ignore stale memory/docs claims otherwise); App `Program.cs` has no subcommand dispatch (the `--session-config` flag is additive). -LA0 through LA10 are review-closed. The launcher composer is now +LA0 through LA11's automated scope are review-closed. The launcher composer is now compiled into both host test suites, and Launcher.Core runs in the portable Windows/Ubuntu CI closure. The self-contained Avalonia launcher, transactional two-host plugin lifetime, shared login-command route, Runtime-owned retail selection state, authored DAT character screen, and crash-safe verified installer plus atomic cross-platform updater/self-updater -are integrated; the combined Release gate passes 13,972 tests / 5 skips. LA11 -automated and connected/visual closeout is the only remaining slice. +are integrated. Windows group-isolated Headless stop, isolated update fixtures, +strict status/redaction evidence, and the exact Windows/Ubuntu operator script +are landed; the integrated preflight passes 32/32 commands and 14,012 tests / +5 skips. Only the connected/visual/real-DAT user gate remains before shipment. **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 5f64967f..4f2cc180 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -103,15 +103,18 @@ a future campaign). Spec: [`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); plan + ledger: [`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). -LA0 through LA10 are review-closed: the portable path boundary, +LA0 through LA11's automated scope are review-closed: the portable path boundary, failure-isolated launch/status contract, BCL-only launcher core, shared composer-to-both-host-loader anti-drift gate, and character wire messages are landed. The self-contained Avalonia launcher, transactional two-host plugin lifetime, shared login-command route, Runtime-owned retail selection state, authored DAT character screen, crash-safe verified installer, and atomic -cross-platform updater/self-updater are integrated. The combined Release gate -passes 13,972 tests / 5 skips. LA11 automated and connected/visual closeout is -the only remaining slice. +cross-platform updater/self-updater are integrated. Windows group-isolated +Headless stop, isolated A/B update fixtures, strict status/redaction evidence, +and one exact Windows/Ubuntu operator script are also landed. The integrated +clean preflight passes 32/32 commands and 14,012 tests / 5 skips. Campaign code +is complete but not shipped: the connected/visual/real-DAT user gate is the +only remaining boundary. **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index d4592726..4541b36f 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -732,4 +732,4 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. | LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. | | LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. | | LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0–LA10 gate: 13,972+5 skip. | -| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`, merge `d39f3098` | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. | +| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`, merge `d39f3098` | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. Integrated clean-head preflight at `a22f5411` passed 32/32 with 14,012 tests + 5 skips and report SHA-256 `49f225bc6043b9256f17b7bf0f29df919c894b8355633077751fd279756470df`. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. | From d54b8a789efd47fefe094a3ed515a758ea5377bc Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 07:31:34 +0200 Subject: [PATCH 058/138] =?UTF-8?q?fix(launcher):=20Campaign=20LA=20?= =?UTF-8?q?=E2=80=94=20MainWindow=20must=20call=20InitializeComponent,=20n?= =?UTF-8?q?ot=20AvaloniaXamlLoader.Load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every click in the launcher exited the process. MainWindow ctor called AvaloniaXamlLoader.Load(this), which loads the XAML tree but never assigns the generated x:Name backing fields, so ProfilesTree, ServerNameTextBox, AccountNameTextBox, CharacterNameTextBox, EditorSubmitButton, FirstRunDatDirectoryTextBox and UpdateCloseButton were all null. Opening or closing any modal calls Focus() on one of them via OnViewModelPropertyChanged, so the NullReferenceException escaped the dispatcher and Program's top-level guard exited 74. A fresh isolated-root start auto-opens the first-run wizard and hit the same line with no click at all. App.axaml.cs keeps AvaloniaXamlLoader.Load - that is the correct idiom for Application.Initialize(), which has no named controls. Verified live: the isolated-root launch that died instantly now stays up with the first-run wizard open and the window responding. Co-Authored-By: Claude Fable 5 --- src/AcDream.Launcher/MainWindow.axaml.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/AcDream.Launcher/MainWindow.axaml.cs b/src/AcDream.Launcher/MainWindow.axaml.cs index cf39085d..90f6c3be 100644 --- a/src/AcDream.Launcher/MainWindow.axaml.cs +++ b/src/AcDream.Launcher/MainWindow.axaml.cs @@ -18,7 +18,13 @@ public sealed partial class MainWindow : Window public MainWindow() { - AvaloniaXamlLoader.Load(this); + // InitializeComponent(), not AvaloniaXamlLoader.Load(this): only the + // generated method assigns the x:Name backing fields. Loading the XAML + // directly leaves every named control (ProfilesTree, the editor text + // boxes, FirstRunDatDirectoryTextBox, UpdateCloseButton, ...) null, so + // the first modal open or close threw NullReferenceException out of the + // dispatcher and took the whole process down through Program's guard. + InitializeComponent(); _statusTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250), From ef9d61045999b1a3e6fa934b624a4290ff59ffaf Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 07:32:06 +0200 Subject: [PATCH 059/138] docs(issues): file #398 and #399 from the launcher gate launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #399 (HIGH, process class): no test constructs MainWindow — the launcher test project is ViewModel-only with no Avalonia headless package, which is how a crash on every modal open/close passed 14,012 green tests and reached the user gate. Fix direction is Avalonia.Headless.XUnit plus a view test that drives every modal open/close, catching the class rather than one spelling. #398 (MODERATE): the top-level guard prints only ex.Message, so the fatal NullReferenceException fixed at d54b8a78 surfaced with no file, line, or frame; diagnosis needed a temporary code edit and rebuild. Fix direction is a redaction-scanned crash file under the data root. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 421731ab..70672e8d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,55 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate + +**Status:** OPEN +**Severity:** HIGH (process class: this gap let #398 — a crash on every +modal open/close — pass 14,012 green tests and reach the user gate) +**Filed:** 2026-08-15 (found while launching the launcher for the LA11 gate) +**Component:** tests/AcDream.Launcher.Tests + +`tests/AcDream.Launcher.Tests` is ViewModel-only — its csproj has no +Avalonia headless package, and no test instantiates `MainWindow` or any +view. `LauncherWindowViewModelTests` proved the modal state machine while +the code-behind that consumes it was never executed once, which is exactly +how #398's null `x:Name` fields survived every automated gate. + +**Fix direction.** Add `Avalonia.Headless.XUnit` to the launcher test +project and a focused view test that constructs `MainWindow`, asserts every +`x:Name` field the code-behind dereferences is non-null, and drives each +modal (editor kinds, first-run wizard, update prompt) through open and +close so the focus/restore paths actually run. That catches the class — +any null named control or throwing handler — not just #398's spelling. + +**Acceptance:** a headless view test fails against the pre-#398 code +(`AvaloniaXamlLoader.Load`) and passes after, and runs in the portable +Windows+Ubuntu CI lane alongside the existing launcher tests. + +## #398 — Launcher: fatal startup/dispatcher exceptions are reported without a stack + +**Status:** OPEN +**Severity:** MODERATE (diagnosability) +**Filed:** 2026-08-15 +**Component:** `src/AcDream.Launcher/Program.cs` + +`Program.Main`'s top-level guard prints only `ex.Message` before returning +74. For the `MainWindow` NullReferenceException fixed at `d54b8a78` that +produced `Launcher startup failed safely: Object reference not set to an +instance of an object.` with no file, line, or frame — diagnosis required +temporarily editing the guard to print the full exception and rebuilding. +An alpha user hitting a crash can give us nothing actionable. + +**Fix direction.** Keep stderr terse, but write the full exception +(`ToString()`, including inner exceptions) to a crash file under the +resolved data root, and print that path. Must honor the LA11 redaction +contract: the crash file is subject to the same credential-value scanning +as the status stream, and must never contain a password or a raw command +line. + +**Acceptance:** a fatal startup exception produces a crash file with the +stack, stderr names its path, and the redaction suite covers the new sink. + ## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host **Status:** IN-PROGRESS — the isolated process-group implementation and real From e1e94697b27e6b506111f54d9a7ae0dc4c284cfc Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 07:42:29 +0200 Subject: [PATCH 060/138] fix(launcher): co-deploy acdream-bake on Build and write a crash report (#398) Two gaps found launching the launcher for the LA11 gate. 1. acdream-bake was co-deployed only AfterTargets=Publish with a RID, so a plain `dotnet build` left the launcher with no bake tool beside it while App.OnFrameworkInitializationCompleted resolves it at AppContext.BaseDirectory/acdream-bake[.exe]. A developer-built launcher therefore reached the first-run wizard with an installer it could never run. CoDeployBakeToolToBuildOutput does for Build what the publish target does for Publish: still NO Launcher -> Bake project reference, still a self-contained single file so exactly one file lands beside the launcher rather than scattering Content/Chorizite assemblies into its output. Staged through obj/ because publishing straight into the launcher output makes the inner publish delete what the outer build just wrote. Inputs/Outputs keep it incremental - verified: 79.6 MB bake exe present, --help exits 0, and a second build skips the republish in ~1 s. 2. #398: the top-level guard printed only ex.Message, so the crash that preceded this commit surfaced with no file, line, or frame. The full exception now goes to a crash-reports file under the resolved data root and stderr names the path. The first implementation wrote to the machine real data root when option parsing itself failed, which broke LA11 process local roots during an isolated run; the reporter now reads --data-dir positionally for that fallback. Verified: report lands inside the isolated root and the real root stays empty. The redaction comment states exactly what is guaranteed - args/environment are never serialized, while exception text may quote an option name or path, which is safe only because credentials never enter launcher state. Launcher.Core 317/317 green. Co-Authored-By: Claude Fable 5 --- src/AcDream.Launcher/AcDream.Launcher.csproj | 50 +++++++++++ src/AcDream.Launcher/Program.cs | 94 ++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index 350260b2..01a0dfbc 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -12,8 +12,26 @@ true true true + + true + <_BakeExecutableName Condition="$([MSBuild]::IsOSPlatform('Windows'))">acdream-bake.exe + <_BakeExecutableName Condition="'$(_BakeExecutableName)' == ''">acdream-bake + + + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Bake\**\*.cs" + Exclude="$(MSBuildProjectDirectory)\..\AcDream.Bake\bin\**\*.cs;$(MSBuildProjectDirectory)\..\AcDream.Bake\obj\**\*.cs" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj" /> + + @@ -28,6 +46,38 @@ + + + + <_BakeBuildRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier) + <_BakeBuildRid Condition="'$(_BakeBuildRid)' == ''">$(NETCoreSdkPortableRuntimeIdentifier) + <_BakeBuildStagingDirectory>$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)bake-codeploy\$(Configuration)\$(_BakeBuildRid)\ + <_BakeBuildOutputDirectory Condition="$([System.IO.Path]::IsPathRooted('$(OutputPath)'))">$(OutputPath) + <_BakeBuildOutputDirectory Condition="'$(_BakeBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath) + + + + + + + diff --git a/src/AcDream.Launcher/Program.cs b/src/AcDream.Launcher/Program.cs index bb6b7c1f..982aad60 100644 --- a/src/AcDream.Launcher/Program.cs +++ b/src/AcDream.Launcher/Program.cs @@ -1,4 +1,5 @@ using AcDream.Launcher.Core.Updates; +using AcDream.Platform; using Avalonia; namespace AcDream.Launcher; @@ -42,10 +43,103 @@ internal static class Program catch (Exception ex) { Console.Error.WriteLine($"Launcher startup failed safely: {ex.Message}"); + string? report = TryWriteCrashReport(args, ex); + Console.Error.WriteLine(report is null + ? "No crash report could be written." + : $"Crash report: {report}"); return 74; } } + /// + /// Issue #398: stderr alone carried only ex.Message, so a fatal + /// dispatcher exception reached the operator with no file, line, or frame + /// and diagnosis required editing this guard and rebuilding. The full + /// exception goes to a file under the resolved data root instead of to + /// stderr, and the path is printed. + /// + /// Redaction contract, stated exactly. This method writes only the + /// exception chain plus non-identifying host facts; it never serializes + /// , the environment, or process state. It does NOT + /// claim the text is value-free: an exception message may quote whatever + /// the thrower put in it, including an offending option name or a path + /// (observed: "Launcher option '--x' requires a value"). That is acceptable + /// because a credential cannot reach this text by construction — the + /// launcher never holds a password in any field, credentials go straight to + /// a child process's stdin, and LauncherProcessSpec carries no + /// credential member (guarded by its own test). If that ever changes, this + /// sink needs the same credential scanning the status stream has. + /// + /// Never throws: a crash reporter that can itself fail would replace + /// the original failure with its own. + /// + private static string? TryWriteCrashReport(string[] args, Exception failure) + { + try + { + string dataDirectory; + try + { + dataDirectory = LauncherStartupOptions.Parse(args).Paths.DataDirectory; + } + catch + { + // Parsing is one of the things that can fail here, and the + // caller's --data-dir must still be honored: LA11's roots are + // process-local, so a crash report written to the machine's + // real data root during an isolated run would break that + // isolation (observed doing exactly that before this branch + // existed). Read the root positionally without validating it, + // and only fall back to the defaults when it is absent. + dataDirectory = TryReadRequestedDataDirectory(args) + ?? ApplicationPathSet.Resolve().DataDirectory; + } + + string directory = Path.Combine(dataDirectory, "crash-reports"); + Directory.CreateDirectory(directory); + string path = Path.Combine( + directory, + $"launcher-crash-{DateTime.UtcNow:yyyyMMdd-HHmmssfff}.log"); + File.WriteAllText( + path, + $""" + acdream launcher crash report + utc: {DateTime.UtcNow:O} + os: {Environment.OSVersion} + rid: {System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier} + version: {typeof(Program).Assembly.GetName().Version} + + {failure} + """); + return path; + } + catch + { + return null; + } + } + + /// + /// Positional, validation-free read of --data-dir for the crash + /// reporter only, so an isolated run keeps its evidence inside its own + /// roots even when option parsing is what failed. Never used for anything + /// the launcher actually runs on — + /// remains the only validated path authority. + /// + private static string? TryReadRequestedDataDirectory(string[] args) + { + for (int index = 0; index + 1 < args.Length; index++) + { + if (string.Equals(args[index], "--data-dir", StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(args[index + 1])) + { + return args[index + 1]; + } + } + + return null; + } + internal static AppBuilder BuildAvaloniaApp(LauncherStartupOptions options) { ArgumentNullException.ThrowIfNull(options); From 2b439cc1071dfa68b444bf1e25292dd37db0148e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 07:54:49 +0200 Subject: [PATCH 061/138] =?UTF-8?q?test(launcher):=20Campaign=20LA=20?= =?UTF-8?q?=E2=80=94=20headless=20MainWindow=20view=20tests=20close=20#399?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #398 was a crash on every modal open/close caused by MainWindow's constructor calling AvaloniaXamlLoader.Load(this) instead of the generated InitializeComponent() — only InitializeComponent assigns the x:Name backing fields, so every named control was null and the first Dispatcher.UIThread.Post callback in OnViewModelPropertyChanged threw NullReferenceException, killing the process. It reached the user gate because no test in tests/AcDream.Launcher.Tests (ViewModel-only) ever constructed a MainWindow. #399 is the process gap that let that class of defect through 14,012 green tests. Adds Avalonia.Headless.XUnit 12.1.1 to the launcher test project. Its net10.0 dependency group targets xunit v3, so the project migrates xunit 2.9.3 -> xunit.v3 3.2.2 (drop-in: all 54 pre-existing tests compile and pass unchanged under dotnet test via xunit.runner.visualstudio 3.1.4, which already supported v1/v2/v3; two call sites needed TestContext.Current.CancellationToken per the new xUnit1051 analyzer). TestAppBuilder.cs wires [assembly: AvaloniaTestApplication] to a headless AppBuilder.Configure() so the real App.axaml FluentTheme is live in tests. MainWindowViewTests.cs adds 12 [AvaloniaFact]/[AvaloniaTheory] tests: - an explicit non-null + type check of every x:Name field the code-behind dereferences (ProfilesTree, ServerNameTextBox, AccountNameTextBox, CharacterNameTextBox, EditorSubmitButton, FirstRunDatDirectoryTextBox, FirstRunCloseButton, UpdateCloseButton) - a reflection sweep over every x:Name found in MainWindow.axaml, so a future named control without a matching non-null field fails loudly - one open+close round trip per ProfileEditorKind (all seven, including Remove), plus the first-run wizard and the update prompt, each pumping Dispatcher.UIThread.RunJobs() so the queued focus callback actually executes instead of just being asserted vacuously - a dedicated test for the _focusBeforeModal-restore branch (not just the ProfilesTree.Focus() fallback), anchored on a real focusable button since ProfilesTree (TreeView) has Focusable="False" under FluentTheme — its own tab stops are TreeViewItem rows, so the close-path assertions check "no exception escaped the dispatcher" rather than "focus landed on ProfilesTree" Falsification (required evidence): reverting MainWindow's constructor to AvaloniaXamlLoader.Load(this) and rerunning gives 12 failed / 0 passed — 10 tests throw NullReferenceException at MainWindow.FocusActiveModal, propagating cleanly out of Dispatcher.UIThread.RunJobs() (confirming dispatcher exceptions are not silently swallowed), and the 2 reflection tests fail on an explicit "x:Name 'ProfilesTree' was null after construction" message. Restoring InitializeComponent() gives 12 passed / 0 failed. Full launcher suite: 66 passed / 0 failed, reproduced on both Windows and native Ubuntu (WSL, no display/Xvfb — Avalonia.Headless needs none). AcDream.Launcher.Core.Tests: 317/317 unaffected. No CI workflow change needed: .github/workflows/headless-portability.yml's portable-launcher job already runs dotnet test on the launcher test project on both windows-latest and ubuntu-latest with no display setup, which is sufficient for Avalonia.Headless. Closes #399. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 53 ++- .../AcDream.Launcher.Tests.csproj | 3 +- .../LauncherUpdateCompositionTests.cs | 2 +- .../LauncherWindowViewModelTests.cs | 4 +- .../MainWindowViewTests.cs | 406 ++++++++++++++++++ .../AcDream.Launcher.Tests/TestAppBuilder.cs | 21 + 6 files changed, 478 insertions(+), 11 deletions(-) create mode 100644 tests/AcDream.Launcher.Tests/MainWindowViewTests.cs create mode 100644 tests/AcDream.Launcher.Tests/TestAppBuilder.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 70672e8d..c83a90b5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,7 +26,8 @@ What does NOT go here: ## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate -**Status:** OPEN +**Status:** DONE (this commit, Campaign LA UI-test slice) — closed via +`tests/AcDream.Launcher.Tests/MainWindowViewTests.cs`. **Severity:** HIGH (process class: this gap let #398 — a crash on every modal open/close — pass 14,012 green tests and reach the user gate) **Filed:** 2026-08-15 (found while launching the launcher for the LA11 gate) @@ -38,14 +39,50 @@ view. `LauncherWindowViewModelTests` proved the modal state machine while the code-behind that consumes it was never executed once, which is exactly how #398's null `x:Name` fields survived every automated gate. -**Fix direction.** Add `Avalonia.Headless.XUnit` to the launcher test -project and a focused view test that constructs `MainWindow`, asserts every -`x:Name` field the code-behind dereferences is non-null, and drives each -modal (editor kinds, first-run wizard, update prompt) through open and -close so the focus/restore paths actually run. That catches the class — -any null named control or throwing handler — not just #398's spelling. +**Fix landed.** Added `Avalonia.Headless.XUnit` 12.1.1 to the launcher test +project (its net10.0 dependency group targets **xunit v3**, so the project +migrated `xunit` 2.9.3 → `xunit.v3` 3.2.2 — a drop-in swap; all 54 +pre-existing `[Fact]`/`[Theory]`/`Assert.*` tests compiled and passed +unchanged, only two call sites needed `TestContext.Current.CancellationToken` +per the new `xUnit1051` analyzer). `TestAppBuilder` +(`tests/AcDream.Launcher.Tests/TestAppBuilder.cs`) wires +`[assembly: AvaloniaTestApplication]` to a headless `AppBuilder.Configure()` +so FluentTheme (declared in the real `App.axaml`) is live for every test. +`MainWindowViewTests.cs` adds 12 `[AvaloniaFact]`/`[AvaloniaTheory]` tests: +an explicit non-null check of every `x:Name` field the code-behind +dereferences, a reflection sweep over every `x:Name` in the markup (so a +future named control without a matching non-null field fails loudly), and +one open+close round trip per `ProfileEditorKind` (all seven, including +`Remove`) plus the first-run wizard and the update prompt — each pumping +`Dispatcher.UIThread.RunJobs()` so the `Dispatcher.UIThread.Post` callback +in `OnViewModelPropertyChanged`/`FocusActiveModal` actually executes, not +just gets queued. A dedicated test proves the `_focusBeforeModal != null` +restore branch (not just the `ProfilesTree.Focus()` fallback) also runs +clean, anchored on a real focusable button since `ProfilesTree` (a +`TreeView`) has `Focusable="False"` under FluentTheme — its own tab stops +are `TreeViewItem` rows, so the close-path assertions check "no exception +escaped" rather than "focus landed on ProfilesTree" (that would be a false +expectation, not the bug this issue is about). -**Acceptance:** a headless view test fails against the pre-#398 code +**Falsification (required evidence).** Reverting `MainWindow`'s constructor +to `AvaloniaXamlLoader.Load(this)` and rerunning: **12 failed / 0 passed** +— 10 tests throw `System.NullReferenceException` at +`AcDream.Launcher.MainWindow.FocusActiveModal` (propagating cleanly out of +`Dispatcher.UIThread.RunJobs()`, confirming dispatcher exceptions are not +swallowed), the 2 reflection tests fail on an explicit +"`x:Name 'ProfilesTree' was null after construction`" message. Restoring +`InitializeComponent()`: **12 passed / 0 failed**. Full launcher suite: +**66 passed / 0 failed** (Windows and native Ubuntu/WSL, both post-fix). +`tests/AcDream.Launcher.Core.Tests`: 317/317 unaffected. + +**CI.** `.github/workflows/headless-portability.yml`'s `portable-launcher` +job already runs `dotnet test tests/AcDream.Launcher.Tests/...` on both +`windows-latest` and `ubuntu-latest` with no display setup — no workflow +change was needed, since `Avalonia.Headless` requires no real windowing +system (confirmed directly: the new tests pass unmodified under WSL/native +Linux with no `DISPLAY` or Xvfb). + +**Acceptance (met):** a headless view test fails against the pre-#398 code (`AvaloniaXamlLoader.Load`) and passes after, and runs in the portable Windows+Ubuntu CI lane alongside the existing launcher tests. diff --git a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj index 872bec9d..ef2da372 100644 --- a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj +++ b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj @@ -9,10 +9,11 @@ + - + diff --git a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs index d2f2ed98..1cdcae41 100644 --- a/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherUpdateCompositionTests.cs @@ -59,7 +59,7 @@ public sealed class LauncherUpdateCompositionTests : IDisposable Assert.False(capability.IsAvailable); Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal); LauncherUpdateException updateError = await Assert.ThrowsAsync( - () => composition.Updater.CheckAsync()); + () => composition.Updater.CheckAsync(TestContext.Current.CancellationToken)); Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal); } diff --git a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs index 18fd3897..b747e5d9 100644 --- a/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherWindowViewModelTests.cs @@ -389,7 +389,9 @@ public sealed class LauncherWindowViewModelTests viewModel.FirstRunWizardShell.OpenCommand.Execute(null); Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync(); - await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await entered.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null)); Assert.False(viewModel.FirstRunWizardShell.CanEditInputs); viewModel.FirstRunWizardShell.CancelCommand.Execute(null); diff --git a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs new file mode 100644 index 00000000..c117563a --- /dev/null +++ b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs @@ -0,0 +1,406 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using AcDream.Launcher.Core.Installation; +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.ViewModels; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace AcDream.Launcher.Tests; + +/// +/// Closes #399: no test ever constructed , so the +/// #398 defect class (code-behind dereferencing an x:Name field that +/// AvaloniaXamlLoader.Load(this) never assigns, instead of the +/// generated InitializeComponent()) reached the user gate through +/// 14,012 green tests that were all ViewModel-only. +/// +/// Every test here constructs a real against the +/// real compiled XAML and drives it exactly the way App.axaml.cs +/// does: assign a live as +/// DataContext, then exercise the modal open/close paths that +/// dereference the named controls (the bug class lives in +/// MainWindow.axaml.cs's OnViewModelPropertyChanged and +/// FocusActiveModal). That focus work is queued via +/// Dispatcher.UIThread.Post, so every test pumps the headless +/// dispatcher with before asserting — a +/// test that only sets a property and asserts would pass vacuously +/// without ever running FocusActiveModal. +/// +public sealed class MainWindowViewTests +{ + // Every x:Name in MainWindow.axaml, kept in sync with the reflection + // sweep below so a newly-added named control without a matching field + // fails loudly instead of silently reaching InitializeComponent(). + private static readonly (string Name, Type Type)[] ExpectedNamedControls = + [ + ("ProfilesTree", typeof(TreeView)), + ("ServerNameTextBox", typeof(TextBox)), + ("AccountNameTextBox", typeof(TextBox)), + ("CharacterNameTextBox", typeof(TextBox)), + ("EditorSubmitButton", typeof(Button)), + ("FirstRunDatDirectoryTextBox", typeof(TextBox)), + ("FirstRunCloseButton", typeof(Button)), + ("UpdateCloseButton", typeof(Button)), + ]; + + [AvaloniaFact] + public void EveryExplicitlyNamedControlIsAssignedAfterConstruction() + { + var window = new MainWindow(); + + foreach ((string name, Type type) in ExpectedNamedControls) + { + object? value = GetNamedField(window, name); + Assert.True( + value is not null, + $"x:Name '{name}' was null after construction. Only the " + + "generated InitializeComponent() assigns x:Name backing " + + "fields; AvaloniaXamlLoader.Load(this) alone leaves them " + + "null (this is the #398 defect class)."); + Assert.IsAssignableFrom(type, value); + } + } + + [AvaloniaFact] + public void ReflectionSweepOfEveryXNameInMarkupFindsANonNullBackingField() + { + string markupPath = Path.Combine( + FindRepositoryRoot(), + "src", + "AcDream.Launcher", + "MainWindow.axaml"); + string markup = File.ReadAllText(markupPath); + List names = Regex + .Matches(markup, "x:Name=\"([^\"]+)\"") + .Select(match => match.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .ToList(); + + // The markup must still declare at least the controls the + // code-behind dereferences; an empty sweep would make this test + // vacuous. + Assert.True(names.Count >= ExpectedNamedControls.Length); + + var window = new MainWindow(); + foreach (string name in names) + { + FieldInfo? field = typeof(MainWindow).GetField( + name, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.True(field is not null, $"No backing field found for x:Name '{name}'."); + object? value = field!.GetValue(window); + Assert.True( + value is not null, + $"x:Name '{name}' resolved to a field but its value was null " + + "after construction."); + } + } + + [AvaloniaTheory] + [InlineData(ProfileEditorKind.AddServer, "ServerNameTextBox")] + [InlineData(ProfileEditorKind.EditServer, "ServerNameTextBox")] + [InlineData(ProfileEditorKind.AddAccount, "AccountNameTextBox")] + [InlineData(ProfileEditorKind.EditAccount, "AccountNameTextBox")] + [InlineData(ProfileEditorKind.AddCharacter, "CharacterNameTextBox")] + [InlineData(ProfileEditorKind.EditCharacter, "CharacterNameTextBox")] + [InlineData(ProfileEditorKind.Remove, "EditorSubmitButton")] + public void OpeningEachEditorKindFocusesItsPrimaryFieldAndClosingRunsTheFallbackWithoutThrowing( + ProfileEditorKind kind, + string expectedFocusFieldName) + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + viewModel.EditorDialog.Open(kind, "Fixture title", _ => { }); + Assert.True(viewModel.EditorDialog.IsOpen); + Dispatcher.UIThread.RunJobs(); + + Control expectedFocus = (Control)GetNamedField(window, expectedFocusFieldName)!; + Assert.Same(expectedFocus, CurrentFocus(window)); + + viewModel.EditorDialog.Close(); + Assert.False(viewModel.EditorDialog.IsOpen); + + // Nothing held focus before the dialog opened, so + // OnViewModelPropertyChanged's close branch posts the fallback + // (ProfilesTree.Focus()). Pumping the dispatcher is what actually + // *runs* FocusActiveModal's caller and its ProfilesTree + // dereference — this is the #398 defect class: with + // AvaloniaXamlLoader.Load(this) instead of InitializeComponent(), + // ProfilesTree is null here and this throws + // NullReferenceException out of the dispatcher. TreeView's Fluent + // template sets Focusable="False" (focus lives on TreeViewItem + // rows, not the tree itself), so a successful, non-throwing + // ProfilesTree.Focus() call still leaves focus at null — that is + // expected, not a failure. + Dispatcher.UIThread.RunJobs(); + Assert.NotSame(expectedFocus, CurrentFocus(window)); + } + + [AvaloniaFact] + public void OpeningAndClosingTheFirstRunWizardFocusesAndRunsTheCloseFallbackWithoutThrowing() + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + viewModel.FirstRunWizardShell.OpenCommand.Execute(null); + Assert.True(viewModel.FirstRunWizardShell.IsOpen); + Dispatcher.UIThread.RunJobs(); + + Control datDirectoryBox = (Control)GetNamedField(window, "FirstRunDatDirectoryTextBox")!; + Assert.Same(datDirectoryBox, CurrentFocus(window)); + + viewModel.FirstRunWizardShell.CloseCommand.Execute(null); + Assert.False(viewModel.FirstRunWizardShell.IsOpen); + + // See the comment in the editor-kind theory above: this pump is + // what actually executes the ProfilesTree.Focus() fallback. + Dispatcher.UIThread.RunJobs(); + Assert.NotSame(datDirectoryBox, CurrentFocus(window)); + } + + [AvaloniaFact] + public async Task OpeningAndClosingTheUpdatePromptFocusesAndRunsTheCloseFallbackWithoutThrowing() + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + await viewModel.UpdatePrompt.OpenCommand.ExecuteAsync(); + Assert.True(viewModel.UpdatePrompt.IsOpen); + Dispatcher.UIThread.RunJobs(); + + Control closeButton = (Control)GetNamedField(window, "UpdateCloseButton")!; + Assert.Same(closeButton, CurrentFocus(window)); + + viewModel.UpdatePrompt.CloseCommand.Execute(null); + Assert.False(viewModel.UpdatePrompt.IsOpen); + + // See the comment in the editor-kind theory above: this pump is + // what actually executes the ProfilesTree.Focus() fallback. + Dispatcher.UIThread.RunJobs(); + Assert.NotSame(closeButton, CurrentFocus(window)); + } + + [AvaloniaFact] + public void ClosingAModalRestoresThePreviouslyFocusedControlWithoutThrowing() + { + using LauncherWindowViewModel viewModel = CreateViewModel(); + var window = new MainWindow { DataContext = viewModel }; + window.Show(); + + // ProfilesTree itself is not a Fluent focus target (its template + // sets Focusable="False"; individual TreeViewItem rows are the + // real tab stops), so use another genuinely focusable, always + // visible control from the same non-modal chrome as the + // "previously focused" anchor for the _focusBeforeModal != null + // branch of MainWindow.OnViewModelPropertyChanged. + Control addServerButton = window + .GetVisualDescendants() + .OfType - private static string? TryWriteCrashReport(string[] args, Exception failure) + internal static string? TryWriteCrashReport(string[] args, Exception failure) { try { @@ -124,14 +130,18 @@ internal static class Program /// reporter only, so an isolated run keeps its evidence inside its own /// roots even when option parsing is what failed. Never used for anything /// the launcher actually runs on — - /// remains the only validated path authority. + /// remains the only validated path authority. Fully-qualified paths only + /// (gate-round-1 review): a relative or flag-shaped value would create + /// ./<value>/crash-reports wherever the CWD happens to be, + /// which defeats the isolation this fallback exists to preserve. /// private static string? TryReadRequestedDataDirectory(string[] args) { for (int index = 0; index + 1 < args.Length; index++) { if (string.Equals(args[index], "--data-dir", StringComparison.Ordinal) - && !string.IsNullOrWhiteSpace(args[index + 1])) + && !string.IsNullOrWhiteSpace(args[index + 1]) + && Path.IsPathFullyQualified(args[index + 1])) { return args[index + 1]; } diff --git a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs index c117563a..39713aac 100644 --- a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs +++ b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs @@ -1,5 +1,5 @@ using System.Reflection; -using System.Text.RegularExpressions; +using System.Xml.Linq; using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Orchestration; @@ -74,10 +74,23 @@ public sealed class MainWindowViewTests "src", "AcDream.Launcher", "MainWindow.axaml"); - string markup = File.ReadAllText(markupPath); - List names = Regex - .Matches(markup, "x:Name=\"([^\"]+)\"") - .Select(match => match.Groups[1].Value) + // Walk the markup as XML rather than regexing the raw text: + // template-scoped names (inside a DataTemplate/ControlTemplate/ + // ItemTemplate) get NO generated backing field, so demanding one + // would false-fail the first time a template gains an x:Name + // (gate-round-1 review F5 — latent today, MainWindow has two + // templates with none inside). + XDocument document = XDocument.Load(markupPath); + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + List names = document + .Descendants() + .Where(element => element.Attribute(x + "Name") is not null) + .Where(element => !element + .Ancestors() + .Any(ancestor => ancestor.Name.LocalName.EndsWith( + "Template", + StringComparison.Ordinal))) + .Select(element => element.Attribute(x + "Name")!.Value) .Distinct(StringComparer.Ordinal) .ToList(); @@ -260,6 +273,74 @@ public sealed class MainWindowViewTests throw new DirectoryNotFoundException("Could not find AcDream.slnx."); } + /// + /// Gate-round-1 review F1: the crash reporter's safety rests on the + /// invariant that no code path interpolates a credential VALUE into an + /// exception message — the launcher genuinely holds passwords + /// (ProfileEditorDialogViewModel, AccountProfile.Password, + /// StartRequest.Password), so "no password in any field" was never the + /// guarantee. This test pins the real one against the most + /// credential-adjacent realistic failure: a profiles-shaped document + /// that CONTAINS the password and is corrupted AFTER it, so the JSON + /// parser has consumed the credential value before throwing. + /// System.Text.Json quotes paths and positions, never values — if that + /// (or any future throw site) ever changes, this fails and the sink + /// needs the status-stream's credential scanning. + /// + [Fact] + public void CrashReportNeverContainsAStoredPassword() + { + string root = Path.Combine( + Path.GetTempPath(), + "acdream-tests", + Path.GetRandomFileName()); + string dataDirectory = Path.Combine(root, "data"); + const string password = "hunter2-gate-round-1-secret"; + string corruptProfiles = + "{ \"version\": 1, \"servers\": [ { \"name\": \"s\", \"host\": \"h\", " + + "\"port\": 9000, \"accounts\": [ { \"account\": \"a\", \"password\": \"" + + password + + "\", \"characters\": [ } ] } ] }"; + + Exception failure; + try + { + _ = System.Text.Json.JsonSerializer.Deserialize( + corruptProfiles); + throw new InvalidOperationException( + "The corrupt fixture unexpectedly parsed; the test premise is broken."); + } + catch (System.Text.Json.JsonException jsonFailure) + { + failure = new InvalidOperationException( + "Profile load failed during startup.", + jsonFailure); + } + + try + { + string? report = Program.TryWriteCrashReport( + ["--data-dir", dataDirectory], + failure); + + Assert.NotNull(report); + // Isolation re-pinned: the report must land under the caller's + // --data-dir, never the machine's real data root. + Assert.StartsWith(dataDirectory, report, StringComparison.OrdinalIgnoreCase); + string content = File.ReadAllText(report); + Assert.Contains("JsonException", content); + Assert.Contains(" at ", content); + Assert.DoesNotContain(password, content, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + /// /// Minimal no-op orchestrator. These tests exercise MainWindow's own /// dispatcher/focus wiring, not orchestrator behavior (already covered From 6e1c0967cbe8849b82bd3ab7cec07308feb464e2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 08:42:33 +0200 Subject: [PATCH 064/138] =?UTF-8?q?fix(app):=20Campaign=20LA=20gate=20roun?= =?UTF-8?q?d=202=20=E2=80=94=20session-config=20launches=20force=20the=20r?= =?UTF-8?q?etail=20UI=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A launcher-spawned client showed the world with NO interface at all - character screen included. RetailUi rode ACDREAM_RETAIL_UI (the dev-era opt-in), FromSessionConfig inherited the env parse, and the launcher strips ACDREAM_* from children by design, so every product launch got the dev default. A session-config launch IS a product launch: RetailUi is now forced true on that path; the env flag remains the dev-launch opt-in. Pinned by the session-config options test with a null env. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/RuntimeOptions.cs | 8 ++++++++ .../Configuration/RuntimeOptionsSessionConfigTests.cs | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 91772711..155cd8ba 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -245,6 +245,14 @@ public sealed record RuntimeOptions( PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath) ?? baseOptions.PreparedAssetPath, LiveMode = true, + // Campaign LA gate round 2: a session-config launch IS a product + // launch — the retail UI is the shipped UI, not a dev option. + // ACDREAM_RETAIL_UI remains the opt-in for env-var dev launches, + // but the launcher strips ACDREAM_* from children (LA11 isolation), + // so inheriting the env default here shipped a client with world + // rendering and NO interface at all — the guiSelect flow's + // character screen included. + RetailUi = true, LiveHost = session.Endpoint.Host, LivePort = session.Endpoint.Port, LiveUser = session.Account, diff --git a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs index ceba50ad..7fafe02f 100644 --- a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs +++ b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs @@ -59,6 +59,11 @@ public sealed class RuntimeOptionsSessionConfigTests Assert.Equal(["PluginA", "PluginB"], options.Plugins); Assert.Equal(["/tell x, hi"], options.LoginCommands); Assert.Equal(900, options.LoginCommandDelayMs); + // Gate round 2: a session-config launch is a product launch — the + // retail UI must be ON even though the env func above returns null + // for everything (the launcher strips ACDREAM_* from children, so + // env inheritance here shipped a client with no interface at all). + Assert.True(options.RetailUi); } [Fact] From 9ce72925704dbc51d0bcce83827959cf3cad4c47 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 09:12:10 +0200 Subject: [PATCH 065/138] =?UTF-8?q?fix(ui):=20Campaign=20LA=20gate=20round?= =?UTF-8?q?=202=20=E2=80=94=20character-select=20screen=20media=20resoluti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the LA8 character-select screen's root background RenderSurface (0x06007576, LayoutDesc 0x21000004 element 0x1000039A) is PFID_CUSTOM_RAW_JPEG — a complete JFIF byte stream (confirmed live: 414,230 bytes, FFD8...FFD9, Width=0/Height=0 on disk) that SurfaceDecoder.DecodeRenderSurface had no case for, so it fell through the switch's `_ => DecodedTexture.Magenta` default arm with nothing logged. Retail's RenderSurface::CreateFromSourceData (named-retail decomp @0x004440a0) hands this exact byte stream to the Intel JPEG Library (`_ijlInit`/`_ijlRead`/`_ijlFree`) at runtime and reads the real pixel dimensions from the JPEG's own SOF header rather than this RenderSurface's Width/Height fields, which are legitimately 0 for this format — the same reason the decoder's generic non-positive-Width/Height guard was also wrong to apply here. A per-id media sweep of the installed DAT (new EveryDeclaredMediaId_ ResolvesToADecodableTexture test) showed this was the ONLY unresolved id among the screen's 25 distinct media ids — the listbox (0x1000039D) and every button face resolve fine. The listbox interior and the ENTER button's circular fill are both transparent regions layered on top of the root, so the one broken root background bled through everywhere nothing opaque covered it, producing all three symptoms (full-screen background, listbox interior, ENTER circle) from one cause. Fix: SurfaceDecoder now special-cases PFID_CUSTOM_RAW_JPEG before the Width/Height guard and decodes it with StbImageSharp (dual Unlicense/MIT, pure managed, no native dependency — works on the Linux headless/graphical targets Slice K/L commit to). JPEG is ITU T.81-standardized, so any conforming decoder reproduces the pixels IJL would; round-tripped a synthetic fixture through the real decode path to confirm. Verified against the live DAT: 0x06007576 now decodes to 800x600, exactly the screen's LayoutDesc-authored size. Guard: per claude-memory/feedback_ui_resolve_zero_magenta.md, an unresolved id reaching the draw path should be loud. That memory's existing guard ("guard on the id, not the handle") only covers a DIFFERENT trap — a zero/absent id — and could not have caught this one, which has a real, non-zero, DAT-resolved id. No guard existed for "id resolves but can't decode" or "id doesn't exist in either dat" before this change, so both were silent. SurfaceDecoder now logs once per surface id on every magenta-return path (null data, JPEG decode failure, unsupported format, no-palette paletted format, decode exception); TextureCache.GetOrUploadRenderSurface logs once per id when a RenderSurface isn't found in Portal or HighRes at all. Tests: CharacterManagementLiveDatTests.EveryDeclaredMediaId_ ResolvesToADecodableTexture (installed-DAT gate, ACDREAM_PROBE_LIVE_MOUNT=1) sweeps every StateMedia id in the char-select root + listbox row template and asserts none decode to the magenta placeholder — this class of gap now fails the gate instead of shipping silently. SurfaceDecoderTests adds PFID_CUSTOM_RAW_JPEG coverage (real decode via a synthetic from-scratch JPEG fixture — not retail art, generated with StbImageWriteSharp and round-tripped before being pasted in as a literal; corrupt-data and null-SourceData magenta paths) plus PFID_P8/PFID_INDEX16 no-palette cases that now flow through the same logged path. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/Rendering/TextureCache.cs | 13 ++ src/AcDream.Core/AcDream.Core.csproj | 10 ++ src/AcDream.Core/Textures/SurfaceDecoder.cs | 89 ++++++++++++- .../Layout/CharacterManagementLiveDatTests.cs | 123 ++++++++++++++++++ .../Textures/SurfaceDecoderTests.cs | 123 ++++++++++++++++++ 5 files changed, 353 insertions(+), 5 deletions(-) diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index f3e18c42..4a3ead6f 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -40,6 +40,13 @@ public sealed class TextureCache // Surface→SurfaceTexture chain that GetOrUpload uses for world materials. private readonly Dictionary _renderSurfaceGpuTextures = new(); + // Campaign LA gate round 2: the OTHER magenta cause GetOrUploadRenderSurface can + // hit — a non-zero id that simply isn't a RenderSurface in either dat (as opposed + // to SurfaceDecoder's own logged causes for an id that DOES resolve but can't + // decode). Same "loud, not silent" treatment, same log-once-per-id dedup pattern + // already used by EquippedChildRenderController._loggedUnaddressableParentRefusals. + private readonly HashSet _loggedMissingRenderSurfaceIds = new(); + // Ad-hoc textures produced by the public UploadRgba8(byte[],int,int,bool) wrapper // (used by IconComposer for composited item icons). These are NOT stored in any // of the keyed caches above, so Dispose must sweep this list to avoid leaking @@ -231,6 +238,12 @@ public sealed class TextureCache } else { + if (_loggedMissingRenderSurfaceIds.Add(renderSurfaceId)) + { + Console.WriteLine( + $"[UI] TextureCache: RenderSurface 0x{renderSurfaceId:X8} was not " + + "found in Portal or HighRes — drawing the 1x1 magenta placeholder."); + } decoded = DecodedTexture.Magenta; } diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 966b25e1..7c0d4903 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -15,6 +15,16 @@ + + diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 6cbd108f..33a1fefb 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -1,8 +1,10 @@ +using System.Collections.Concurrent; using AcDream.Core.Rendering.Wb; using BCnEncoder.Decoder; using BCnEncoder.Shared; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; +using StbImageSharp; namespace AcDream.Core.Textures; @@ -10,6 +12,34 @@ public static class SurfaceDecoder { private static readonly BcDecoder BcDecoder = new(); + /// + /// Campaign LA gate round 2 (character-select screen): a real, DAT-resolved, + /// non-zero-id RenderSurface can still hit the magenta fallback below (unsupported + /// PixelFormat, a paletted format with no palette, or corrupt/undersized + /// SourceData). That is a DIFFERENT trap than the zero-id footgun documented in + /// claude-memory/feedback_ui_resolve_zero_magenta.md ("guard on the id, not + /// the handle") — this one has a real id and a real handle, so that guard cannot + /// catch it. Both traps produce the identical silent 1x1 magenta texture, so this + /// one needs the same "loud, not silent" treatment: log once per surface id so an + /// undecodable asset fails LOUD in diagnostics instead of shipping as a silent + /// magenta wash (this is exactly how LA8's character-select background, + /// RenderSurface 0x06007576/PFID_CUSTOM_RAW_JPEG, went unnoticed — nothing logged + /// when its decode fell through to the unsupported-format arm). + /// + private static readonly ConcurrentDictionary LoggedMagentaIds = new(); + + private static DecodedTexture LogMagentaOnce(RenderSurface rs, string reason) + { + if (LoggedMagentaIds.TryAdd(rs.Id, 0)) + { + Console.WriteLine( + $"[UI] SurfaceDecoder: RenderSurface 0x{rs.Id:X8} decoded to the 1x1 " + + $"magenta placeholder ({reason}; format={rs.Format} " + + $"{rs.Width}x{rs.Height})."); + } + return DecodedTexture.Magenta; + } + /// /// Decode a RenderSurface's pixel bytes into RGBA8. Returns /// for unsupported formats, null data, or corrupt sizing. This overload does NOT @@ -31,8 +61,35 @@ public static class SurfaceDecoder /// public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false) { - if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) - return DecodedTexture.Magenta; + if (rs.SourceData is null) + return LogMagentaOnce(rs, "null SourceData"); + + // PFID_CUSTOM_RAW_JPEG carries a complete JFIF-encoded image verbatim in + // SourceData. Retail's RenderSurface::CreateFromSourceData (named-retail + // decomp @0x004440a0) hands this exact byte stream to the Intel JPEG Library + // (`_ijlInit`/`_ijlRead`/`_ijlFree`) at RUNTIME, and the real pixel dimensions + // come from the JPEG's own SOF header — NOT from this RenderSurface's + // Width/Height fields, which are legitimately 0 on disk for this format + // (confirmed against the installed DAT: 0x06007576, the LA8 character-select + // screen's root background, carries Width=0/Height=0 with a 414,230-byte + // FFD8...FFD9 JFIF stream that decodes to 800x600 — exactly the screen's + // LayoutDesc-authored size). Handle it before the generic Width/Height guard + // below, which does not apply to this format and previously made every + // PFID_CUSTOM_RAW_JPEG surface fall straight to magenta. + if (rs.Format == PixelFormat.PFID_CUSTOM_RAW_JPEG) + { + try + { + return DecodeCustomRawJpeg(rs); + } + catch (Exception ex) + { + return LogMagentaOnce(rs, $"JPEG decode failed: {ex.Message}"); + } + } + + if (rs.Width <= 0 || rs.Height <= 0) + return LogMagentaOnce(rs, "non-positive Width/Height"); try { @@ -46,18 +103,40 @@ public static class SurfaceDecoder PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap), PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive), PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap), + PixelFormat.PFID_P8 => LogMagentaOnce(rs, "PFID_P8 with no palette"), PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), + PixelFormat.PFID_INDEX16 => LogMagentaOnce(rs, "PFID_INDEX16 with no palette"), PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs), PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs), - _ => DecodedTexture.Magenta, + _ => LogMagentaOnce(rs, $"unsupported PixelFormat {rs.Format}"), }; } - catch + catch (Exception ex) { - return DecodedTexture.Magenta; + return LogMagentaOnce(rs, $"decode threw: {ex.Message}"); } } + /// + /// Decode PFID_CUSTOM_RAW_JPEG: see the doc comment on the + /// branch in + /// for the + /// retail mechanism this replaces. JPEG is a standardized (ITU T.81) format, so any + /// conforming decoder reproduces the same pixels the Intel JPEG Library would. + /// StbImageSharp (dual Unlicense/MIT, pure managed, no native dependency) is + /// acdream's decoder so the same code path works on the Linux headless/graphical + /// targets Slice K/L commit to. Throws on any failure; the caller converts that + /// into the logged magenta placeholder — this method never returns Magenta itself. + /// + private static DecodedTexture DecodeCustomRawJpeg(RenderSurface rs) + { + ImageResult image = ImageResult.FromMemory(rs.SourceData!, ColorComponents.RedGreenBlueAlpha); + if (image.Width <= 0 || image.Height <= 0) + throw new InvalidDataException( + $"JPEG surface 0x{rs.Id:X8} decoded to {image.Width}x{image.Height}."); + return new DecodedTexture(image.Data, image.Width, image.Height); + } + private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap) { int expectedBytes = rs.Width * rs.Height * 2; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 88ceb7b0..18266e64 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -2,8 +2,11 @@ using System.IO; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Content; +using AcDream.Core.Textures; using DatReaderWriter; using DatReaderWriter.Options; +using Palette = DatReaderWriter.DBObjs.Palette; +using RenderSurface = DatReaderWriter.DBObjs.RenderSurface; using StringTable = DatReaderWriter.DBObjs.StringTable; namespace AcDream.App.Tests.UI.Layout; @@ -122,6 +125,126 @@ public sealed class CharacterManagementLiveDatTests Assert.Equal([DatStringResolver.PlayerVariable], deleteEntry.Variables); } + /// + /// Campaign LA gate round 2 regression gate: EVERY non-zero StateMedia image id + /// declared anywhere in the char-select screen's imported element tree (root + + /// descendants + the listbox row template) must resolve to a RenderSurface in + /// Portal/HighRes AND decode to something other than the 1x1 magenta placeholder. + /// This is the class of gap that shipped LA8's full-screen background, listbox + /// interior, and ENTER circular-fill magenta defect: root background 0x06007576 + /// is PFID_CUSTOM_RAW_JPEG (a verbatim JFIF stream — see + /// 's PFID_CUSTOM_RAW_JPEG + /// branch for the retail mechanism), which the decoder previously had no case for + /// and silently fell through to magenta. The listbox (0x1000039D) itself carries + /// no own background media — it is a transparent container, so the fix for the + /// ONE root id also cleared the listbox-interior and ENTER-circle symptoms (both + /// were the broken root bleeding through transparent regions on top of it), which + /// this test's per-id sweep proves by finding no OTHER magenta id. + /// + [InstalledDatFact] + public void EveryDeclaredMediaId_ResolvesToADecodableTexture() + { + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDirectory, DatAccessType.Read); + + uint layoutDid = RetailDataIdResolver.Resolve( + dats, + CharacterManagementUiController.RootEnum, + 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, + layoutDid, + CharacterManagementUiController.RootElementId)); + + var ids = new SortedDictionary(); + CollectMediaIds(rootInfo, "root", ids); + + // Also walk the listbox's row template — it is imported separately by + // AddItemFromTemplateList/TemplateResolver, not as a root descendant. + ElementInfo? listInfo = FindById(rootInfo, CharacterManagementUiController.ListElementId); + Assert.NotNull(listInfo); + Assert.NotEmpty(listInfo!.TemplateList); + foreach (var entry in listInfo.TemplateList) + { + ElementInfo? rowInfo = LayoutImporter.ImportInfos( + dats, entry.TemplateLayoutId, entry.TemplateElementId); + Assert.NotNull(rowInfo); + CollectMediaIds(rowInfo!, "row-template", ids); + } + + Console.WriteLine($"[LA8-DIAG] layout=0x{layoutDid:X8} distinct media ids={ids.Count}"); + Assert.NotEmpty(ids); + // The known root-background id must actually be present in this sweep — + // otherwise the assertions below would vacuously pass without ever having + // exercised the surface that caused the defect. + Assert.Contains(0x06007576u, ids.Keys); + + var unresolved = new List(); + foreach (var (id, where) in ids) + { + bool found = dats.Portal.TryGet(id, out RenderSurface? rs) + || dats.HighRes.TryGet(id, out rs); + if (!found) + { + Console.WriteLine($"[LA8-DIAG] 0x{id:X8} ({where}): NOT FOUND in Portal or HighRes"); + unresolved.Add($"0x{id:X8} ({where}): missing RenderSurface"); + continue; + } + + Palette? palette = rs!.DefaultPaletteId != 0 + ? dats.Get(rs.DefaultPaletteId) + : null; + DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette); + bool magenta = decoded.Width == 1 && decoded.Height == 1 + && decoded.Rgba8 is [0xFF, 0x00, 0xFF, 0xFF]; + Console.WriteLine( + $"[LA8-DIAG] 0x{id:X8} ({where}): format={rs.Format} " + + $"{rs.Width}x{rs.Height} defaultPalette=0x{rs.DefaultPaletteId:X8} " + + $"paletteLoaded={(palette is not null)} decoded={decoded.Width}x{decoded.Height} " + + $"magenta={magenta}"); + if (magenta) + unresolved.Add( + $"0x{id:X8} ({where}): format={rs.Format} defaultPalette=0x{rs.DefaultPaletteId:X8} " + + $"paletteLoaded={(palette is not null)}"); + } + + Assert.True( + unresolved.Count == 0, + "Media ids that resolved to the 1x1 magenta placeholder:\n" + + string.Join('\n', unresolved)); + } + + private static ElementInfo? FindById(ElementInfo info, uint id) + { + if (info.Id == id) return info; + foreach (ElementInfo child in info.Children) + { + ElementInfo? found = FindById(child, id); + if (found is not null) return found; + } + return null; + } + + private static void CollectMediaIds(ElementInfo info, string where, SortedDictionary ids) + { + foreach (var (stateName, media) in info.StateMedia) + { + if (media.File == 0) continue; + string label = $"{where} elem=0x{info.Id:X8} type={info.Type} state='{stateName}'"; + if (!ids.ContainsKey(media.File)) + ids[media.File] = label; + else + ids[media.File] += " | " + label; + } + foreach (ElementInfo child in info.Children) + CollectMediaIds(child, where, ids); + } + private static ImportedLayout BuildSelected( IDatReaderWriter dats, uint layoutDid, diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs index e50ff372..4ef5949d 100644 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -468,4 +468,127 @@ public class SurfaceDecoderTests Assert.Same(DecodedTexture.Magenta, decoded); } + + // ---- PFID_CUSTOM_RAW_JPEG tests (Campaign LA gate round 2) --------------- + // + // TinyJpeg8x8 is a synthetic, from-scratch-generated 8x8 JFIF image (top-left + // 4x4 quadrant ~RGB(200,30,40), bottom-right 4x4 quadrant ~RGB(20,40,220)) — + // NOT extracted from any retail asset. It exists purely so these tests exercise + // the REAL JPEG codepath end-to-end without embedding copyrighted game art in + // the repo. Generated once with StbImageWriteSharp and round-tripped through + // StbImageSharp to confirm fidelity before being pasted in as a literal. + + private static readonly byte[] TinyJpeg8x8 = + [ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x84, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, + 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0A, 0x07, + 0x07, 0x06, 0x08, 0x0C, 0x0A, 0x0C, 0x0C, 0x0B, 0x0A, 0x0B, 0x0B, 0x0D, 0x0E, 0x12, 0x10, 0x0D, + 0x0E, 0x11, 0x0E, 0x0B, 0x0B, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0C, 0x0F, + 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0x01, 0x03, 0x04, 0x04, 0x05, 0x04, 0x05, + 0x09, 0x05, 0x05, 0x09, 0x14, 0x0D, 0x0B, 0x0D, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, + 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x01, + 0xA2, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x10, 0x00, + 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, + 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, + 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, + 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, + 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, + 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, + 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, + 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, + 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x11, 0x00, + 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, + 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, + 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, + 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, + 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, + 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, + 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, + 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, + 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xF9, + 0x37, 0x59, 0xD6, 0x7F, 0xB5, 0xFC, 0x9F, 0xDC, 0xF9, 0x5E, 0x5E, 0x7F, 0x8B, 0x76, 0x73, 0x8F, + 0x6F, 0x6A, 0xCD, 0xA2, 0x8A, 0xFE, 0xE5, 0xCB, 0x32, 0xCC, 0x26, 0x4F, 0x84, 0x86, 0x07, 0x03, + 0x0E, 0x4A, 0x50, 0xBD, 0x95, 0xDB, 0xB5, 0xDB, 0x6F, 0x56, 0xDB, 0xDD, 0xB7, 0xAB, 0x3E, 0x3F, + 0x31, 0xCC, 0x71, 0x59, 0xB6, 0x2A, 0x78, 0xDC, 0x6C, 0xF9, 0xAA, 0x4A, 0xD7, 0x76, 0x4A, 0xF6, + 0x49, 0x2D, 0x12, 0x4B, 0x64, 0xBA, 0x1F, 0xFF, 0xD9, + ]; + + [Fact] + public void Decode_CustomRawJpeg_DecodesRealPixels() + { + // Mirrors the real dat encoding for this format: RenderSurface.Width/Height + // are 0 (confirmed against the installed DAT's LA8 character-select + // background, 0x06007576 — see CharacterManagementLiveDatTests). Dimensions + // and pixels must come from the JPEG's own SOF header instead. + var rs = new RenderSurface + { + Width = 0, + Height = 0, + Format = PixelFormat.PFID_CUSTOM_RAW_JPEG, + SourceData = TinyJpeg8x8, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.NotSame(DecodedTexture.Magenta, decoded); + Assert.Equal(8, decoded.Width); + Assert.Equal(8, decoded.Height); + Assert.Equal(8 * 8 * 4, decoded.Rgba8.Length); + + // Top-left quadrant was authored ~RGB(200,30,40); bottom-right ~RGB(20,40,220). + // JPEG is lossy, so assert within a generous tolerance rather than exact bytes. + int topLeft = (1 * decoded.Width + 1) * 4; + Assert.InRange(decoded.Rgba8[topLeft + 0], 170, 230); // R + Assert.InRange(decoded.Rgba8[topLeft + 2], 10, 70); // B + Assert.Equal(0xFF, decoded.Rgba8[topLeft + 3]); // JPEG has no alpha channel + + int bottomRight = (6 * decoded.Width + 6) * 4; + Assert.InRange(decoded.Rgba8[bottomRight + 0], 0, 60); // R + Assert.InRange(decoded.Rgba8[bottomRight + 2], 190, 255); // B + Assert.Equal(0xFF, decoded.Rgba8[bottomRight + 3]); + } + + [Fact] + public void Decode_CustomRawJpeg_CorruptData_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 0, + Height = 0, + Format = PixelFormat.PFID_CUSTOM_RAW_JPEG, + SourceData = [0x01, 0x02, 0x03, 0x04], // not a JPEG stream at all + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } + + [Fact] + public void Decode_CustomRawJpeg_NullSourceData_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 0, + Height = 0, + Format = PixelFormat.PFID_CUSTOM_RAW_JPEG, + SourceData = null!, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } } From 936077d57639e5a89b6f7f7d75124dd8c0b13685 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 09:13:45 +0200 Subject: [PATCH 066/138] =?UTF-8?q?docs:=20Campaign=20LA=20gate=20round=20?= =?UTF-8?q?2=20record=20=E2=80=94=20retail-UI=20product=20default=20+=20JP?= =?UTF-8?q?EG=20background=20decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 327effdb..bfdb815f 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -756,6 +756,42 @@ optional Path.IsPathFullyQualified hardening on the crash reporter's --data-dir fallback. Launcher 67/67, Launcher.Core 317/317. The §A–I connected script remains the open user gate. +## Gate round 2 — 2026-08-15 (first live launcher→client flow) + +Two real defects, both root-caused and fixed: + +1. **`6e1c0967` — launcher-spawned clients had NO interface at all.** + `RetailUi` rode the dev env var `ACDREAM_RETAIL_UI`; `FromSessionConfig` + inherited the env parse; the launcher strips `ACDREAM_*` from children + (LA11 isolation). Product launches therefore got the dev default: world + rendering, zero UI — character screen included. Session-config launches + now force `RetailUi = true` (a session-config launch IS a product + launch); the env flag remains the dev-launch opt-in. Test-pinned with a + null env. +2. **`9ce72925` — character-select screen rendered magenta background/ + fills.** The screen's 800×600 root background (`0x06007576`) is + `PFID_CUSTOM_RAW_JPEG` — a complete JFIF stream retail hands to the + Intel JPEG Library (`RenderSurface::CreateFromSourceData @0x004440a0`), + with Width/Height legitimately 0 on disk. `SurfaceDecoder` had no JPEG + case AND a non-positive-dimension guard, so it fell silently to the + magenta placeholder; the listbox/ENTER fills are transparent, so one + broken background bled through as three symptoms. Fixed via + StbImageSharp (managed, Linux-safe; codec-library substitution per the + BCnEncoder precedent — no register row). BOTH silent traps now log once + per id (id-resolves-but-undecodable in `SurfaceDecoder`; + id-missing-from-DATs in `TextureCache`) — the existing magenta guard + only covered id-0. New installed-DAT sweep asserts every char-select + media id decodes non-magenta. Full suite 14,034 green. + +Session-orchestration facts this round: the machine gained PowerShell 7 +(winget, user-approved — the LA fixture tooling hard-requires it); an +orphan feed server from the earlier session held port 43119 with stale +fixture data (stopped); the launcher self-update bootstrap restart on a +dev binary is EXPECTED (staged launcher update → exit → respawn). +Observations still open for this round: the duplicated "versioned client +is unavailable" status line (cosmetic), and verifying Create Character is +disabled on the live screen. + ## Ledger | Slice | Status | Commits | Review | Notes | From 71bf24fb6f11bf537f8bcc464a2b3dd0a9f1c316 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 09:42:53 +0200 Subject: [PATCH 067/138] =?UTF-8?q?fix(ui):=20Campaign=20LA=20gate=20round?= =?UTF-8?q?=202=20=E2=80=94=20character-select=20root=20background=20stret?= =?UTF-8?q?ches,=20never=20tiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LA8 char-select root (0x1000039A) authors LeftEdge=TopEdge=RightEdge= BottomEdge=0 ("no anchor") in the installed DAT — confirmed via the new CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf gate — so retail's own UIElement::UpdateForParentSizeChange (0x00462640) never resizes this element; it stays a fixed 800x600 rect in retail's own tree. Retail's generic UI sprite blit, Graphic::Draw (0x00693b20) dispatching to Graphic::PutImage (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode — confirmed against BlitMode (acclient.h ~3135) and MD_Data_Image::m_drawMode/DrawModeType, both COLOR-blend selectors, not tile-vs-stretch geometry modes. The prior "Normal -> tile, matching ImgTex::TileCSI" citation in UiDatElement was a mis-attribution: ImgTex::TileCSI (0x0053e740) is called exclusively from TexMerge::CopyAndTile/ImgTex::CopyCSI for LAND-SURFACE terrain texture compositing, never from the UI element system. Given the dat authors zero resize anchors and the blitter can only copy or tile, the only way retail's whole pre-world scene (background + buttons + listbox together) fills an arbitrary window resolution is that these fixed-canvas "flow" screens render at 800x600 and the WHOLE FRAME is stretched once at presentation — outside the UI sprite system entirely. acdream has no offscreen fixed-resolution UI render target / present-time scale pass; CharacterManagementUiController's constructor instead resizes the MOUNTED ROOT element itself to the live viewport, which is why its own background tiled (Width/tw > 1 at any resolution above 800x600, wrapped by GL_REPEAT). Fix: UiDatElement gains StretchOwnBackgroundToFill (default false, every ordinary chrome/container element keeps tiling) — when set, the element's own DirectState background draws as one UV-0..1 quad instead of the native tile formula. CharacterManagementUiController sets it on Root right where Root is resized to the host viewport, reaching the same visual result as retail's present-time stretch (no tiling, no aspect-preserving letterbox) through a different mechanism. Divergence register row AD-98 records the substitution. Tests: three new UiDatElementTests pin the UV-span mechanism generically (tile past 1.0 when unset and rect exceeds native size; clamped to 1.0 when set; byte-identical to the old tile formula when rect equals native size, so every unaffected panel is untouched). CharacterManagementUiControllerTests pins Root.StretchOwnBackgroundToFill == true post-construction. The live-DAT gate confirms the root's zero edge-anchors and Type=3 against the installed DAT. AcDream.App.Tests: 5084 passed / 3 skipped with ACDREAM_PROBE_LIVE_MOUNT=1 (5081/6 skipped without it — the 3 live-DAT-gated tests skip). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../Layout/CharacterManagementUiController.cs | 10 ++ src/AcDream.App/UI/Layout/UiDatElement.cs | 78 +++++++++++- .../Layout/CharacterManagementLiveDatTests.cs | 43 +++++++ .../CharacterManagementUiControllerTests.cs | 16 +++ .../UI/Layout/UiDatElementTests.cs | 111 ++++++++++++++++++ 6 files changed, 256 insertions(+), 5 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f9b38e6b..981a496f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 73 active rows (AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 74 active rows (AD-98 filed 2026-08-15 at Campaign LA gate round 2 — the char-select root's own background stretches instead of tiling by resizing the mounted root element to the live viewport and marking its background quad UV 0..1, substituting for retail's fixed-800x600-canvas-stretched-at-presentation mechanism which acdream's live-resolution render pipeline has no analogue for; AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -189,6 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | +| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. acdream has no offscreen fixed-resolution UI render target / present-time scale pass; `CharacterManagementUiController`'s constructor instead resizes the MOUNTED ROOT element itself to the live viewport, and `UiDatElement.StretchOwnBackgroundToFill` makes that resized root's own background draw as one UV-0..1 quad instead of tiling. | `src/AcDream.App/UI/Layout/UiDatElement.cs` (`StretchOwnBackgroundToFill`, `OnDraw`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (constructor) | Reproducing retail's real mechanism (a fixed 800x600 UI render target scaled at presentation) would touch the render/swapchain pipeline (`GameWindow`, framebuffer setup) far beyond a background-draw fix; resizing the mounted root and stretching only ITS OWN background quad reaches the identical visual result (no tiling, non-uniform fill matching every resolution) confirmed against the installed DAT's zero edge-anchors and the decompiled blitter's copy-or-tile-only behavior. | If acdream ever gains a genuine fixed-resolution UI render target + present-time scale pass, this flag becomes redundant (every root would already present pre-stretched) and should be deleted along with the per-root resize in `CharacterManagementUiController`. Until then, any OTHER screen-level root mounted the same way (a future login/disconnected/datapatch screen) needs the same flag set explicitly — it is not automatic for arbitrary `UiDatElement`s. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020 (`UIElement::SetIsRootElement`); `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf` | | 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 | diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index cd5aa341..ff5b34a7 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -83,6 +83,16 @@ internal sealed class CharacterManagementUiController : IDisposable Root.ClickThrough = false; Root.Visible = false; + // Campaign LA gate round 2: this root is resized to the live viewport just + // above, which is bigger than its authored 800x600 canvas at almost every + // real resolution. Its own DirectState background (RenderSurface 0x06007576) + // must scale to fill that resized rect, not tile — see + // UiDatElement.StretchOwnBackgroundToFill's doc comment for the retail + // mechanism (a fixed-canvas screen stretched once at presentation) this + // substitutes. + if (Root is UiDatElement rootBackground) + rootBackground.StretchOwnBackgroundToFill = true; + // Create Character belongs to a future campaign. Keep retail's // authored control in place and visibly ghosted; do not hide it or // invent an action. diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 584a4d5e..2277e087 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -205,6 +205,63 @@ public class UiDatElement : UiElement, IUiDatStateful /// public uint? RuntimeImageTexture { get; set; } + /// + /// When true, this element's OWN active-state background media draws as ONE quad + /// stretched to exactly fill / + /// (UV span 0,0 .. 1,1) instead of the native-pixel TILE formula every other + /// uses. Default false — every ordinary dat chrome/ + /// container element (corners, edges, drag bars, tab backdrops) keeps tiling. + /// + /// + /// Campaign LA gate round 2 (issue found in the live client: the LA8 + /// character-select background repeated across the window instead of scaling + /// with it). Retail's generic UI sprite blit — + /// Graphic::Draw (acclient 0x00693b20) dispatching to + /// Graphic::PutImage (0x00693a30) for an exact/undersized destination, or a + /// modulo-wrapped tile loop otherwise — has exactly two behaviors, copy or tile; + /// it can never scale a source image up to a larger destination. This is confirmed + /// against two candidate "draw-mode" fields that could have carried a stretch bit + /// and don't: BlitMode (acclient.h ~line 3135 — Blit_Normal/3Alpha/4Alpha/ + /// Colorize/Multiply/Screen/Grayscale/NOP are all COLOR-BLEND selectors) and + /// MD_Data_Image::m_drawMode/DrawModeType (Undefined/Normal/Overlay/ + /// Alphablend — also a blend selector; the "Normal → tile" reading in + /// docs/research/2026-06-15-layoutdesc-format.md §6 cited + /// ImgTex::TileCSI (0x0053e740), but that function is exclusively called from + /// TexMerge::CopyAndTile/ImgTex::CopyCSI for LAND-SURFACE terrain + /// texture compositing (TerrainTex) — never from the UI element system; the + /// citation was a coincidental name match, not the real call site). + /// + /// + /// + /// The LA8 root itself (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 + /// ("no anchor" — confirmed against the installed DAT via + /// CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf), + /// so retail's own UIElement::UpdateForParentSizeChange (0x00462640) never + /// touches this element's size at all — it stays a fixed 800x600 rect. The only way + /// retail's whole pre-world "flow" scene (background AND buttons AND listbox + /// together — "the background scales with the root") can still fill an arbitrary + /// window resolution edge-to-edge, with the generic sprite blit only ever able to + /// copy-or-tile, is that these screens render into a fixed, authored-size (800x600) + /// target and the WHOLE FRAME is stretched once at presentation — a step entirely + /// outside the UIRegion/Graphic::Draw sprite system. + /// + /// + /// + /// acdream has no offscreen fixed-resolution UI render target / present-time scale + /// pass — instead + /// resizes the MOUNTED ROOT ELEMENT itself to the live viewport (see its + /// constructor) so the screen still fills the window. This flag is the acknowledged + /// divergence for that substitution (register row: acdream resizes the element, + /// retail stretches the presented frame) — it makes the resized ROOT's own + /// background draw as one stretched quad so the VISUAL RESULT matches retail's + /// present-time stretch (no tiling) even though the MECHANISM differs. Set only on + /// a screen-level mounted root, never on an ordinary descendant/chrome element — + /// those keep the native tile formula, which IS what retail's own blit does for + /// content that lives inside the (in retail) fixed 800x600 canvas. + /// + /// + public bool StretchOwnBackgroundToFill { get; set; } + protected override void OnDraw(UiRenderContext ctx) { if (MediaVisible && RuntimeImageTexture is uint runtimeTexture) @@ -233,10 +290,23 @@ public class UiDatElement : UiElement, IUiDatStateful var (tex, tw, th) = _resolve(file); if (tex != 0 && tw != 0 && th != 0) { - // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI - // texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the - // sprite shader already alpha-blends). No Stretch mode exists in DrawModeType. - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + if (StretchOwnBackgroundToFill) + { + // One quad, UV 0..1 — see StretchOwnBackgroundToFill's doc comment + // for the retail mechanism this substitutes (a fixed-canvas screen + // stretched once at presentation). + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One); + } + else + { + // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped + // UI texture) — retail's Graphic::Draw/Graphic::PutImage (0x00693b20/ + // 0x00693a30) copy-or-tile blit; see StretchOwnBackgroundToFill's doc + // comment for the corrected citation (NOT ImgTex::TileCSI, which is + // land-surface-only). Overlay/Alphablend use the same blit (the sprite + // shader already alpha-blends). No Stretch mode exists in DrawModeType. + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + } } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 18266e64..4a0e911a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -219,6 +219,49 @@ public sealed class CharacterManagementLiveDatTests + string.Join('\n', unresolved)); } + /// + /// Campaign LA gate round 2 (background-tiling investigation): the raw dat + /// authors NO edge anchors at all on the char-select root (0x1000039A) — every + /// one of LeftEdge/TopEdge/RightEdge/BottomEdge is 0 ("no anchor" per + /// UIElement::UpdateForParentSizeChange, acclient 0x00462640). Retail's + /// own edge-anchor resize mechanism therefore NEVER touches this element's size; + /// it stays a fixed 800x600 rect in retail's own widget tree. This is the pivot + /// fact behind : + /// since the dat itself asks for no resize, whatever makes the char-select scene + /// fill an arbitrary window resolution in retail (background AND buttons AND + /// listbox together) cannot be a per-element anchor/draw-mode difference — it has + /// to be an out-of-band presentation-time scale of the whole fixed-size frame. + /// acdream instead resizes the MOUNTED root itself (CharacterManagementUiController's + /// constructor) to reach the same visual fill, which is why the background needs + /// its own explicit stretch flag rather than an authored draw-mode bit. + /// + [InstalledDatFact] + public void RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf() + { + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDirectory, DatAccessType.Read); + + uint layoutDid = RetailDataIdResolver.Resolve( + dats, + CharacterManagementUiController.RootEnum, + 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, + layoutDid, + CharacterManagementUiController.RootElementId)); + + Assert.Equal(0u, rootInfo.Left); + Assert.Equal(0u, rootInfo.Top); + Assert.Equal(0u, rootInfo.Right); + Assert.Equal(0u, rootInfo.Bottom); + Assert.Equal(3u, rootInfo.Type); // UIElement_Field — generic container, not a custom gm*UI class id + } + private static ElementInfo? FindById(ElementInfo info, uint id) { if (info.Id == id) return info; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index d9708e38..d7c77b57 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -8,6 +8,22 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class CharacterManagementUiControllerTests { + /// + /// Campaign LA gate round 2: the constructor resizes Root to the host viewport + /// (see the constructor's Root.Width/Height block) — its own background must + /// therefore draw stretched, not tiled, or it visibly repeats at any resolution + /// bigger than the authored 800x600 canvas. See + /// . + /// + [Fact] + public void Constructor_MarksRootBackgroundToStretch_NotTile() + { + using var environment = new EnvironmentHarness(); + + var root = Assert.IsType(environment.Controller.Root); + Assert.True(root.StretchOwnBackgroundToFill); + } + [Fact] public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix() { diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs index 5bf05158..9543e166 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -1,9 +1,120 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; public class UiDatElementTests { + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static (TextRenderer renderer, UiRenderContext ctx) BuildRenderContext() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(1920f, 1080f)); + var ctx = new UiRenderContext(renderer, new Vector2(1920f, 1080f)); + return (renderer, ctx); + } + + /// + /// Campaign LA gate round 2: the char-select root's background (native 800x600, + /// resolved from a JPEG surface) was drawn with the ordinary UiDatElement TILE + /// UV formula (u1 = Width/tw) after CharacterManagementUiController resized the + /// root to the live viewport — at 1920x1080 that produces u1 = 2.4, v1 = 1.8, + /// which GL_REPEAT wraps into a visibly tiled background instead of one stretched + /// image. See 's doc comment + /// for the retail mechanism this substitutes. + /// + [Fact] + public void StretchOwnBackgroundToFill_False_TilesUvPastOne_WhenRectExceedsNativeSize() + { + var info = new ElementInfo { Width = 1920, Height = 1080 }; + info.StateMedia[""] = (0x06007576u, 1); + var e = new UiDatElement(info, _ => (7u, 800, 600)) + { + Left = 0, + Top = 0, + Width = 1920, + Height = 1080, + }; + + (TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext(); + e.DrawSelfAndChildren(ctx); + + var (texture, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts); + Assert.Equal(7u, texture); + // Vertex layout: x,y,u,v,r,g,b,a, 6 verts/quad. TextRenderer.AppendQuad emits + // vertex index 1 as (x+w, y+h, u1, v1) — the (u1,v1) far corner. + float uMax = verts[1 * 8 + 2]; + float vMax = verts[1 * 8 + 3]; + Assert.Equal(1920f / 800f, uMax, 3); + Assert.Equal(1080f / 600f, vMax, 3); + } + + /// + /// Campaign LA gate round 2 fix: with the flag set, the SAME oversized rect draws + /// as one quad spanning UV 0..1 — a single stretched image, matching retail's + /// observed (never-tiled) char-select background. + /// + [Fact] + public void StretchOwnBackgroundToFill_True_ClampsUvToOne_WhenRectExceedsNativeSize() + { + var info = new ElementInfo { Width = 1920, Height = 1080 }; + info.StateMedia[""] = (0x06007576u, 1); + var e = new UiDatElement(info, _ => (7u, 800, 600)) + { + Left = 0, + Top = 0, + Width = 1920, + Height = 1080, + StretchOwnBackgroundToFill = true, + }; + + (TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext(); + e.DrawSelfAndChildren(ctx); + + var (texture, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts); + Assert.Equal(7u, texture); + float uMax = verts[1 * 8 + 2]; + float vMax = verts[1 * 8 + 3]; + Assert.Equal(1f, uMax, 3); + Assert.Equal(1f, vMax, 3); + } + + /// + /// The flag must not change anything for an element whose rect already matches + /// its native texture size (every ordinary panel/window root today) — stretch + /// (UV 0..1) and tile (UV Width/tw) are numerically identical at that size, so + /// this only changes behavior for elements deliberately grown past their art. + /// + [Fact] + public void StretchOwnBackgroundToFill_True_MatchesTile_WhenRectEqualsNativeSize() + { + var info = new ElementInfo { Width = 800, Height = 600 }; + info.StateMedia[""] = (0x06007576u, 1); + var e = new UiDatElement(info, _ => (7u, 800, 600)) + { + Left = 0, + Top = 0, + Width = 800, + Height = 600, + StretchOwnBackgroundToFill = true, + }; + + (TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext(); + e.DrawSelfAndChildren(ctx); + + var (_, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts); + Assert.Equal(1f, verts[1 * 8 + 2], 3); + Assert.Equal(1f, verts[1 * 8 + 3], 3); + } + [Fact] public void ActiveMedia_PrefersNamedStateOverDirect() { From 73041d7015e0964d7971e902b08354c6d6314159 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 10:39:19 +0200 Subject: [PATCH 068/138] =?UTF-8?q?fix(ui):=20Campaign=20LA=20gate=20round?= =?UTF-8?q?=202=20=E2=80=94=20character-select=20scales=20as=20one=20autho?= =?UTF-8?q?red=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third iteration on the screen, completing AD-98. The previous substitution stretched only the root BACKGROUND while the child widgets stayed at their authored 800x600 pixel positions - and the background painting carries visual anchors (the World/Characters captions are art), so the user gate showed captions overlapping the listbox and every widget misaligned against the stretched art. Retail model (established at 71bf24fb): fixed-canvas pre-world screens render at authored 800x600 and the whole composed frame stretches once at presentation; the blitter has no stretch mode. Our equivalent now does the same one stage earlier: - UiRoot.FixedCanvasSize: while the char-select screen is active, the retained tree lays out in its authored canvas and Draw scopes a uniform scale onto TextRenderer.CanvasScale; the mouse entry points apply the exact inverse so MouseX/MouseY and every hit test live in canvas space. - TextRenderer.AppendQuad is the single emission chokepoint - sprites, rects, AND glyphs scale together, including retail-authentic non-uniform aspect distortion and stretched text. World-space HUD stays native (the scale resets outside UiRoot.Draw). - CharacterManagementUiController stops resizing Root to the viewport; activate/deactivate/dispose set and clear the host canvas. - UiDatElement returns to retail-pure copy-or-tile; the interim StretchOwnBackgroundToFill flag is deleted. - AD-98 updated to describe the completed substitution. Tests: canvas-scale quad math, inverse input mapping (window click lands on the canvas-space widget), degenerate-size guards, controller keeps authored extent + sets/clears the canvas. App suite 5085/6 skips; live-DAT char-select probes 3/3. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/TextRenderer.cs | 28 +++- .../Layout/CharacterManagementUiController.cs | 32 ++--- src/AcDream.App/UI/Layout/UiDatElement.cs | 54 +++----- src/AcDream.App/UI/UiRoot.cs | 44 +++++++ .../CharacterManagementUiControllerTests.cs | 33 +++-- .../UI/Layout/UiDatElementTests.cs | 69 ++++------ .../UI/UiRootFixedCanvasTests.cs | 121 ++++++++++++++++++ 8 files changed, 277 insertions(+), 106 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 981a496f..aef92786 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -189,7 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | -| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. acdream has no offscreen fixed-resolution UI render target / present-time scale pass; `CharacterManagementUiController`'s constructor instead resizes the MOUNTED ROOT element itself to the live viewport, and `UiDatElement.StretchOwnBackgroundToFill` makes that resized root's own background draw as one UV-0..1 quad instead of tiling. | `src/AcDream.App/UI/Layout/UiDatElement.cs` (`StretchOwnBackgroundToFill`, `OnDraw`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (constructor) | Reproducing retail's real mechanism (a fixed 800x600 UI render target scaled at presentation) would touch the render/swapchain pipeline (`GameWindow`, framebuffer setup) far beyond a background-draw fix; resizing the mounted root and stretching only ITS OWN background quad reaches the identical visual result (no tiling, non-uniform fill matching every resolution) confirmed against the installed DAT's zero edge-anchors and the decompiled blitter's copy-or-tile-only behavior. | If acdream ever gains a genuine fixed-resolution UI render target + present-time scale pass, this flag becomes redundant (every root would already present pre-stretched) and should be deleted along with the per-root resize in `CharacterManagementUiController`. Until then, any OTHER screen-level root mounted the same way (a future login/disconnected/datapatch screen) needs the same flag set explicitly — it is not automatic for arbitrary `UiDatElement`s. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020 (`UIElement::SetIsRootElement`); `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf` | +| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored` | | 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 | diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 8d64280d..4aac444e 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -201,6 +201,23 @@ public sealed class TextRenderer : IDisposable }); } + /// + /// Campaign LA gate round 2 (register AD-98): uniform canvas scale applied + /// to every emitted quad — sprites, rects, AND glyphs — at the single + /// emission chokepoint (). Retail renders its + /// fixed-canvas pre-world screens (char select's authored 800×600, root + /// 0x1000039A, zero edge anchors) at authored size and stretches the whole + /// composed frame once at presentation; its UI blitter has no stretch mode + /// at all (Graphic::Draw @0x00693b20 is copy-or-tile only). We have no + /// present-time frame stretch, so the equivalent lives here: while a + /// fixed-canvas screen is active, sets this for the + /// duration of its Draw and everything scales together — including retail's + /// characteristic non-uniform aspect distortion and stretched glyphs. + /// UVs and colors are untouched. Always reset to One outside UiRoot.Draw + /// so the world-space HUD keeps native pixels. + /// + internal Vector2 CanvasScale = Vector2.One; + /// Begin a HUD pass. Call once per frame before any Draw* calls. public void Begin(Vector2 screenSize) { @@ -388,10 +405,19 @@ public sealed class TextRenderer : IDisposable return ns; } - private static void AppendQuad(List buf, + private void AppendQuad(List buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) { + // AD-98 canvas stretch — see CanvasScale's doc comment. Applied after + // all canvas-space clipping, so geometry and UVs stay consistent. + if (CanvasScale != Vector2.One) + { + x *= CanvasScale.X; + y *= CanvasScale.Y; + w *= CanvasScale.X; + h *= CanvasScale.Y; + } // Two triangles (6 verts). CCW in pixel space is clockwise in NDC // because the vertex shader flips Y, so OpenGL's default front-face // is GL_CCW — we rely on cull-face being disabled during HUD pass. diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index ff5b34a7..bc242a25 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -38,6 +38,7 @@ internal sealed class CharacterManagementUiController : IDisposable private readonly List _rows = []; private readonly Dictionary _rowIds = []; + private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; private long _lastRevision = long.MinValue; private uint _deleteDialogContext; @@ -74,24 +75,22 @@ internal sealed class CharacterManagementUiController : IDisposable Root.Left = 0f; Root.Top = 0f; - Root.Anchors = AnchorEdges.Left | AnchorEdges.Top - | AnchorEdges.Right | AnchorEdges.Bottom; - if (host.Width > 0f) - Root.Width = host.Width; - if (host.Height > 0f) - Root.Height = host.Height; Root.ClickThrough = false; Root.Visible = false; - // Campaign LA gate round 2: this root is resized to the live viewport just - // above, which is bigger than its authored 800x600 canvas at almost every - // real resolution. Its own DirectState background (RenderSurface 0x06007576) - // must scale to fill that resized rect, not tile — see - // UiDatElement.StretchOwnBackgroundToFill's doc comment for the retail - // mechanism (a fixed-canvas screen stretched once at presentation) this - // substitutes. - if (Root is UiDatElement rootBackground) - rootBackground.StretchOwnBackgroundToFill = true; + // Campaign LA gate round 2 (register AD-98): the root KEEPS its authored + // 800×600 extent — retail never resizes it (zero edge anchors, verified + // against the installed DAT) and its blitter has no stretch mode; the + // whole composed screen stretches once at presentation. Our equivalent: + // while this screen is active, the host stretches the ENTIRE canvas — + // widgets, glyphs, and the painted background (which carries the + // "World"/"Characters" captions as art) — as one unit via + // UiRoot.FixedCanvasSize. Resizing the root here instead is exactly the + // half-substitution that misaligned the widgets against the stretched + // art at the 2026-08-15 user gate. + _authoredCanvas = new Vector2( + Root.Width > 0f ? Root.Width : 800f, + Root.Height > 0f ? Root.Height : 600f); // Create Character belongs to a future campaign. Keep retail's // authored control in place and visibly ghosted; do not hide it or @@ -246,6 +245,7 @@ internal sealed class CharacterManagementUiController : IDisposable { _active = true; Root.Visible = true; + _host.FixedCanvasSize = _authoredCanvas; _host.BringToFront(Root); } @@ -310,6 +310,7 @@ internal sealed class CharacterManagementUiController : IDisposable } finally { + _host.FixedCanvasSize = null; _enter.OnClick = null; _delete.OnClick = null; _restore.OnClick = null; @@ -665,6 +666,7 @@ internal sealed class CharacterManagementUiController : IDisposable { _active = false; Root.Visible = false; + _host.FixedCanvasSize = null; } foreach (UiButton row in _rows) { diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 2277e087..a132e6b1 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -206,11 +206,9 @@ public class UiDatElement : UiElement, IUiDatStateful public uint? RuntimeImageTexture { get; set; } /// - /// When true, this element's OWN active-state background media draws as ONE quad - /// stretched to exactly fill / - /// (UV span 0,0 .. 1,1) instead of the native-pixel TILE formula every other - /// uses. Default false — every ordinary dat chrome/ - /// container element (corners, edges, drag bars, tab backdrops) keeps tiling. + /// Retail background-blit ground truth (Campaign LA gate round 2, register + /// AD-98). Every element draws its own media with the native-pixel TILE + /// formula below — retail has no per-element stretch, and neither do we. /// /// /// Campaign LA gate round 2 (issue found in the live client: the LA8 @@ -247,21 +245,16 @@ public class UiDatElement : UiElement, IUiDatStateful /// /// /// - /// acdream has no offscreen fixed-resolution UI render target / present-time scale - /// pass — instead - /// resizes the MOUNTED ROOT ELEMENT itself to the live viewport (see its - /// constructor) so the screen still fills the window. This flag is the acknowledged - /// divergence for that substitution (register row: acdream resizes the element, - /// retail stretches the presented frame) — it makes the resized ROOT's own - /// background draw as one stretched quad so the VISUAL RESULT matches retail's - /// present-time stretch (no tiling) even though the MECHANISM differs. Set only on - /// a screen-level mounted root, never on an ordinary descendant/chrome element — - /// those keep the native tile formula, which IS what retail's own blit does for - /// content that lives inside the (in retail) fixed 800x600 canvas. + /// acdream's equivalent of that present-time stretch is + /// : while a fixed-canvas + /// screen (char select) is active, the WHOLE retained tree — this tile draw + /// included — is scaled uniformly at the renderer's quad chokepoint, with the + /// inverse applied to mouse input. Elements therefore keep their authored + /// canvas-space sizes here, and the tile formula stays exactly retail's: + /// inside the authored canvas an element never exceeds its media's native + /// span unless retail itself tiled it. /// /// - public bool StretchOwnBackgroundToFill { get; set; } - protected override void OnDraw(UiRenderContext ctx) { if (MediaVisible && RuntimeImageTexture is uint runtimeTexture) @@ -290,23 +283,14 @@ public class UiDatElement : UiElement, IUiDatStateful var (tex, tw, th) = _resolve(file); if (tex != 0 && tw != 0 && th != 0) { - if (StretchOwnBackgroundToFill) - { - // One quad, UV 0..1 — see StretchOwnBackgroundToFill's doc comment - // for the retail mechanism this substitutes (a fixed-canvas screen - // stretched once at presentation). - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One); - } - else - { - // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped - // UI texture) — retail's Graphic::Draw/Graphic::PutImage (0x00693b20/ - // 0x00693a30) copy-or-tile blit; see StretchOwnBackgroundToFill's doc - // comment for the corrected citation (NOT ImgTex::TileCSI, which is - // land-surface-only). Overlay/Alphablend use the same blit (the sprite - // shader already alpha-blends). No Stretch mode exists in DrawModeType. - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } + // TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped + // UI texture) — retail's Graphic::Draw/Graphic::PutImage + // (0x00693b20/0x00693a30) copy-or-tile blit; NOT ImgTex::TileCSI, + // which is land-surface-only (corrected citation, see the class + // doc). Overlay/Alphablend use the same blit (the sprite shader + // already alpha-blends). No Stretch mode exists in DrawModeType; + // whole-canvas stretching happens at UiRoot.FixedCanvasSize. + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); } } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 6ca8c472..fe3246b1 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -32,6 +32,32 @@ public sealed class UiRoot : UiElement /// Single owner for named retained-window lifecycle and raise policy. public RetailWindowManager WindowManager { get; } + /// + /// Campaign LA gate round 2 (register AD-98): when set, the retained tree + /// is laid out in this fixed authored canvas (the char-select screen's + /// 800×600) and the whole tree — widgets, glyphs, art — is stretched to + /// the window as one unit, matching retail's present-time frame stretch + /// for fixed-canvas pre-world screens. Draw applies the scale at the + /// renderer's quad chokepoint; the mouse entry points apply the inverse, + /// so / and every hit test live + /// in canvas space. Null (the in-world default) is native 1:1. + /// + public Vector2? FixedCanvasSize { get; set; } + + /// Window→canvas stretch factor; One when no fixed canvas is set. + public Vector2 CanvasScale => + FixedCanvasSize is { X: > 0f, Y: > 0f } canvas && Width > 0f && Height > 0f + ? new Vector2(Width / canvas.X, Height / canvas.Y) + : Vector2.One; + + private (int x, int y) MapWindowToCanvas(int x, int y) + { + Vector2 scale = CanvasScale; + return scale == Vector2.One + ? (x, y) + : ((int)MathF.Round(x / scale.X), (int)MathF.Round(y / scale.Y)); + } + // ── Device-level state ─────────────────────────────────────────────── public int MouseX { get; private set; } public int MouseY { get; private set; } @@ -370,6 +396,21 @@ public sealed class UiRoot : UiElement } public void Draw(UiRenderContext ctx) + { + // AD-98 fixed-canvas stretch: scope the renderer's canvas scale to + // exactly this tree's draws (world-space HUD stays native). + ctx.TextRenderer.CanvasScale = CanvasScale; + try + { + DrawCore(ctx); + } + finally + { + ctx.TextRenderer.CanvasScale = Vector2.One; + } + } + + private void DrawCore(UiRenderContext ctx) { // Render children (panels) sorted by z-order — modal last so it // sits on top. @@ -401,6 +442,7 @@ public sealed class UiRoot : UiElement public void OnMouseMove(int x, int y) { + (x, y) = MapWindowToCanvas(x, y); int dx = x - MouseX; int dy = y - MouseY; MouseX = x; @@ -552,6 +594,7 @@ public sealed class UiRoot : UiElement public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0) { + (x, y) = MapWindowToCanvas(x, y); MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: true); _pressX = x; _pressY = y; @@ -707,6 +750,7 @@ public sealed class UiRoot : UiElement public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0) { + (x, y) = MapWindowToCanvas(x, y); MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: false); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index d7c77b57..d1f73ead 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -9,19 +9,34 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class CharacterManagementUiControllerTests { /// - /// Campaign LA gate round 2: the constructor resizes Root to the host viewport - /// (see the constructor's Root.Width/Height block) — its own background must - /// therefore draw stretched, not tiled, or it visibly repeats at any resolution - /// bigger than the authored 800x600 canvas. See - /// . + /// Campaign LA gate round 2 (register AD-98): the root KEEPS its authored + /// 800×600 extent (retail never resizes it — zero edge anchors), and while + /// the screen is active the HOST carries the fixed canvas so the whole tree + /// — widgets, glyphs, and the painted background whose art contains the + /// World/Characters captions — stretches together. Resizing the root while + /// stretching only the art is exactly the misalignment the 2026-08-15 user + /// gate caught. Dispose must release the canvas so in-world UI returns to + /// native pixels. /// [Fact] - public void Constructor_MarksRootBackgroundToStretch_NotTile() + public void ActiveScreen_KeepsAuthoredRootExtent_AndSetsHostFixedCanvas() { - using var environment = new EnvironmentHarness(); + var environment = new EnvironmentHarness(); + try + { + UiElement root = environment.Controller.Root; + Assert.Equal(800f, root.Width); + Assert.Equal(600f, root.Height); + Assert.Equal( + new Vector2(root.Width, root.Height), + environment.Host.FixedCanvasSize); + } + finally + { + environment.Dispose(); + } - var root = Assert.IsType(environment.Controller.Root); - Assert.True(root.StretchOwnBackgroundToFill); + Assert.Null(environment.Host.FixedCanvasSize); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs index 9543e166..ac4d8f89 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -23,16 +23,14 @@ public class UiDatElementTests } /// - /// Campaign LA gate round 2: the char-select root's background (native 800x600, - /// resolved from a JPEG surface) was drawn with the ordinary UiDatElement TILE - /// UV formula (u1 = Width/tw) after CharacterManagementUiController resized the - /// root to the live viewport — at 1920x1080 that produces u1 = 2.4, v1 = 1.8, - /// which GL_REPEAT wraps into a visibly tiled background instead of one stretched - /// image. See 's doc comment - /// for the retail mechanism this substitutes. + /// Retail's blit is copy-or-tile only (Graphic::Draw @0x00693b20 — no + /// stretch mode exists), so an element grown past its media's native size + /// tiles: u1 = Width/tw. Whole-screen stretching is NOT this layer's job — + /// it happens uniformly at UiRoot.FixedCanvasSize / TextRenderer.CanvasScale + /// (register AD-98), covered by the test below. /// [Fact] - public void StretchOwnBackgroundToFill_False_TilesUvPastOne_WhenRectExceedsNativeSize() + public void OwnBackground_TilesUvPastOne_WhenRectExceedsNativeSize() { var info = new ElementInfo { Width = 1920, Height = 1080 }; info.StateMedia[""] = (0x06007576u, 1); @@ -58,43 +56,14 @@ public class UiDatElementTests } /// - /// Campaign LA gate round 2 fix: with the flag set, the SAME oversized rect draws - /// as one quad spanning UV 0..1 — a single stretched image, matching retail's - /// observed (never-tiled) char-select background. + /// AD-98 whole-canvas stretch: with the renderer's CanvasScale set (the + /// char-select 800×600 canvas on a 1920×1080 window), an element drawn at + /// authored size emits a quad scaled by exactly (2.4, 1.8) in GEOMETRY while + /// its UVs stay authored (0..1 here) — one stretched image, no tiling, the + /// same math retail's present-time frame stretch produces. /// [Fact] - public void StretchOwnBackgroundToFill_True_ClampsUvToOne_WhenRectExceedsNativeSize() - { - var info = new ElementInfo { Width = 1920, Height = 1080 }; - info.StateMedia[""] = (0x06007576u, 1); - var e = new UiDatElement(info, _ => (7u, 800, 600)) - { - Left = 0, - Top = 0, - Width = 1920, - Height = 1080, - StretchOwnBackgroundToFill = true, - }; - - (TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext(); - e.DrawSelfAndChildren(ctx); - - var (texture, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts); - Assert.Equal(7u, texture); - float uMax = verts[1 * 8 + 2]; - float vMax = verts[1 * 8 + 3]; - Assert.Equal(1f, uMax, 3); - Assert.Equal(1f, vMax, 3); - } - - /// - /// The flag must not change anything for an element whose rect already matches - /// its native texture size (every ordinary panel/window root today) — stretch - /// (UV 0..1) and tile (UV Width/tw) are numerically identical at that size, so - /// this only changes behavior for elements deliberately grown past their art. - /// - [Fact] - public void StretchOwnBackgroundToFill_True_MatchesTile_WhenRectEqualsNativeSize() + public void CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored() { var info = new ElementInfo { Width = 800, Height = 600 }; info.StateMedia[""] = (0x06007576u, 1); @@ -104,13 +73,23 @@ public class UiDatElementTests Top = 0, Width = 800, Height = 600, - StretchOwnBackgroundToFill = true, }; (TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext(); - e.DrawSelfAndChildren(ctx); + renderer.CanvasScale = new Vector2(1920f / 800f, 1080f / 600f); + try + { + e.DrawSelfAndChildren(ctx); + } + finally + { + renderer.CanvasScale = Vector2.One; + } var (_, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts); + // Far corner (vertex 1): geometry scaled to the window, UVs authored. + Assert.Equal(1920f, verts[1 * 8 + 0], 3); + Assert.Equal(1080f, verts[1 * 8 + 1], 3); Assert.Equal(1f, verts[1 * 8 + 2], 3); Assert.Equal(1f, verts[1 * 8 + 3], 3); } diff --git a/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs b/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs new file mode 100644 index 00000000..79a6a6a9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs @@ -0,0 +1,121 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign LA gate round 2 (register AD-98): retail renders fixed-canvas +/// pre-world screens (char select, authored 800×600) at authored size and +/// stretches the whole composed frame at presentation — its UI blitter has no +/// stretch mode. acdream's equivalent is : +/// draw applies one uniform scale at the renderer's quad chokepoint, and the +/// mouse entry points apply the exact inverse so hit-testing lives in canvas +/// space. These tests pin the scale math and the inverse input mapping — the +/// half that, if wrong, makes the user click on art and hit nothing. +/// +public class UiRootFixedCanvasTests +{ + [Fact] + public void CanvasScale_IsOne_WithoutFixedCanvas() + { + var root = new UiRoot { Width = 1920f, Height = 1080f }; + Assert.Equal(Vector2.One, root.CanvasScale); + } + + [Fact] + public void CanvasScale_IsWindowOverCanvas_WhenFixed() + { + var root = new UiRoot + { + Width = 1920f, + Height = 1080f, + FixedCanvasSize = new Vector2(800f, 600f), + }; + Assert.Equal(new Vector2(2.4f, 1.8f), root.CanvasScale); + } + + [Fact] + public void CanvasScale_IsOne_ForDegenerateCanvasOrWindow() + { + var zeroCanvas = new UiRoot + { + Width = 1920f, + Height = 1080f, + FixedCanvasSize = new Vector2(0f, 600f), + }; + Assert.Equal(Vector2.One, zeroCanvas.CanvasScale); + + var zeroWindow = new UiRoot + { + Width = 0f, + Height = 0f, + FixedCanvasSize = new Vector2(800f, 600f), + }; + Assert.Equal(Vector2.One, zeroWindow.CanvasScale); + } + + /// + /// The load-bearing inverse: a click at WINDOW coordinates must land on the + /// widget whose authored CANVAS rect the user visually clicked. The button + /// sits at canvas (300,400)+(120×40); at a 1920×1080 window over an 800×600 + /// canvas it appears at window (720,720)-(1008,792). Clicking window + /// (860,750) — canvas (358,417) — must click it; clicking window (300,400) + /// — canvas (125,222), visually empty — must not. + /// + [Fact] + public void MouseInput_MapsWindowCoordsToCanvasSpace() + { + var root = new UiRoot + { + Width = 1920f, + Height = 1080f, + FixedCanvasSize = new Vector2(800f, 600f), + }; + int clicks = 0; + var button = new UiButton( + new ElementInfo { Width = 120, Height = 40 }, + _ => (0u, 0, 0)) + { + Left = 300f, + Top = 400f, + Width = 120f, + Height = 40f, + OnClick = () => clicks++, + }; + root.AddChild(button); + + root.OnMouseDown(UiMouseButton.Left, 860, 750); + root.OnMouseUp(UiMouseButton.Left, 860, 750); + Assert.Equal(1, clicks); + Assert.Equal(358, root.MouseX); + Assert.Equal(417, root.MouseY); + + root.OnMouseDown(UiMouseButton.Left, 300, 400); + root.OnMouseUp(UiMouseButton.Left, 300, 400); + Assert.Equal(1, clicks); + } + + [Fact] + public void MouseInput_IsUntouched_WithoutFixedCanvas() + { + var root = new UiRoot { Width = 1920f, Height = 1080f }; + int clicks = 0; + var button = new UiButton( + new ElementInfo { Width = 120, Height = 40 }, + _ => (0u, 0, 0)) + { + Left = 300f, + Top = 400f, + Width = 120f, + Height = 40f, + OnClick = () => clicks++, + }; + root.AddChild(button); + + root.OnMouseDown(UiMouseButton.Left, 360, 420); + root.OnMouseUp(UiMouseButton.Left, 360, 420); + Assert.Equal(1, clicks); + } +} From 308f40a3fb4848d8c6af32fa80dde986881efcbe Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 11:05:59 +0200 Subject: [PATCH 069/138] =?UTF-8?q?fix(ui):=20Campaign=20LA=20gate=20round?= =?UTF-8?q?=202=20=E2=80=94=20fixed-canvas=20stretch=20filters=20bilinearl?= =?UTF-8?q?y=20like=20retail's=20presentation=20blit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AD-98's fixed-canvas stretch (73041d70) scales every retained-UI quad at TextRenderer.AppendQuad, but the live gate reported it JAGGED — text especially. Cause: dat-font glyph atlases and IconComposer's composited icons upload nearest (TextureCache.UploadUiTexture's UiNearestRepeat sampler) — correct at the native 1:1 scale (pixel-exact retail art), but aliased once magnified 2.4x1.8. Chrome/background art was already fine: it uploads through GpuSamplerDescription.WorldRepeat (linear) by default. Retail's own fixed-canvas presentation is a single bilinear-filtered frame blit, never a per-texture stretch — this closes that gap one step earlier, at the source texture, without adding RHI surface area. - TextureCache.GetOrCreateLinearUiTwin: lazily registers a SECOND table slot for a nearest handle's IGpuTexture, sampled WorldRepeat (linear) instead of nearest — no re-decode, no re-upload, no extra memory-ledger bytes. Returns the handle unchanged for anything never registered nearest (chrome, UiTextureTableHandle.None), so it's a cheap unconditional probe. Twin slots are released in Dispose without double-disposing the shared texture. - TextRenderer.LinearTwinResolver + the DrawSprite chokepoint: swaps a sprite's texture handle through the resolver only while CanvasScale != One. At CanvasScale == One the resolver is never even called — zero overhead on the ordinary in-world/UI path. - InteractionRetainedUiComposition wires the resolver to TextureCache right after every UiHost acquisition (the lease can hand back a host from a prior session against a fresh TextureCache). - AD-98's register row gets one added sentence recording the fix. Tests: TextRendererLinearTwinTests pins the renderer-side handle-swap seam GPU-free (segment handle selection); TextureCacheLinearTwinTests pins twin creation/reuse/dispose against RecordingGpuDevice. App suite 5097/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1 live-DAT probes included). Full solution builds clean. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../InteractionRetainedUiComposition.cs | 6 + src/AcDream.App/Rendering/TextRenderer.cs | 24 ++++ src/AcDream.App/Rendering/TextureCache.cs | 85 ++++++++++++ .../Rendering/TextRendererLinearTwinTests.cs | 106 +++++++++++++++ .../Rendering/TextureCacheLinearTwinTests.cs | 124 ++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index aef92786..6e05a0c9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -189,7 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | -| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored` | +| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored` | | 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 | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 4b16c5fd..b54b1f67 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -484,6 +484,12 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.DebugFont, d.HostQuiescence)); checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired); + // AD-98 filtering fidelity: re-wired unconditionally on every + // composition, same as the UiLocked assignment below — the lease can + // hand back a HOST from a previous session while d.TextureCache is a + // fresh instance for this one, so a stale resolver would keep + // resolving twins against a disposed TextureCache. + host.TextRenderer.LinearTwinResolver = d.TextureCache.GetOrCreateLinearUiTwin; inputCapture = d.RetainedInputCapture.Bind(host.Root); checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound); // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 4aac444e..c66882f4 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -218,6 +218,21 @@ public sealed class TextRenderer : IDisposable /// internal Vector2 CanvasScale = Vector2.One; + /// + /// Campaign LA gate round 2 (register AD-98 filtering fidelity): resolves a + /// UI texture handle to its linear-sampled twin + /// (), consulted by + /// only while is not One. + /// Wired once by the composition root right after TextureCache exists; + /// left null by any test/host that never sets it, in which case a scaled + /// draw keeps sampling its original slot — nearest stays nearest, exactly + /// today's (jagged) behavior, rather than throwing. Nearest-sampled dat-font + /// glyphs and composited icons are the only handles this ever changes — + /// see the resolver's own doc comment for why chrome/background art passes + /// through unchanged. + /// + internal Func? LinearTwinResolver { get; set; } + /// Begin a HUD pass. Call once per frame before any Draw* calls. public void Begin(Vector2 screenSize) { @@ -365,6 +380,15 @@ public sealed class TextRenderer : IDisposable public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) { + // AD-98 filtering fidelity: while a fixed-canvas screen is stretching + // every quad (CanvasScale != One), sample nearest-registered handles + // through their linear twin instead — see LinearTwinResolver's doc + // comment. The resolver itself is the identity for any handle that + // isn't a nearest-sampled UI texture, so this is safe to call + // unconditionally rather than needing its own "is this nearest" check. + if (CanvasScale != Vector2.One && LinearTwinResolver is { } resolve) + texture = resolve(texture); + SpriteSeg seg = OverlayMode ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture) : NextSpriteSeg(_spriteSegs, ref _segUsed, texture); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 4a3ead6f..7dd8a312 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -53,6 +53,20 @@ public sealed class TextureCache // GPU texture objects/slots until process exit. private readonly List _adhocGpuTextures = new(); + // Campaign LA gate round 2 (AD-98 filtering fidelity): the ORIGINAL IGpuTexture + // behind every handle UploadUiTexture registered nearest (dat-font glyph + // atlases, IconComposer's composited icons). Populated at upload time so + // GetOrCreateLinearUiTwin never has to search either keyed family above to + // find the pixels a twin should reuse. Chrome/background art (nearest: false) + // never enters this table — it already samples GpuSamplerDescription.WorldRepeat + // (linear) and has no twin to create. + private readonly Dictionary _nearestUiTextureSources = new(); + + // The LINEAR-sampled twin handle for a nearest handle, created lazily by + // GetOrCreateLinearUiTwin on its first request and reused after. Empty for + // the lifetime of a session that never activates a fixed-canvas screen. + private readonly Dictionary _linearUiTwinHandles = new(); + private readonly CompositeTextureArrayCache? _compositeTextures; private bool _destinationRevealUploadPriority; @@ -359,6 +373,14 @@ public sealed class TextureCache IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); + uint handle = UiTextureTableHandle.FromSlot(slot); + if (nearest) + { + // AD-98 filtering fidelity: remember the source texture under its + // handle so a fixed-canvas screen can request a linear twin of it + // later without re-decoding. See GetOrCreateLinearUiTwin. + _nearestUiTextureSources[handle] = texture; + } return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); } catch @@ -368,6 +390,60 @@ public sealed class TextureCache } } + /// + /// Campaign LA gate round 2 (register AD-98): the LINEAR-sampled twin of a + /// nearest-sampled UI texture handle, created and table-registered the first + /// time it is requested and reused after. + /// + /// + /// Nearest is correct at the UI's native 1:1 scale — it is what makes + /// dat-font glyphs and composited item icons pixel-exact retail art. Retail's + /// own fixed-canvas pre-world screens never stretch a source texture at all: + /// they compose at authored size and the WHOLE FRAME goes through a single + /// bilinear-filtered presentation blit (see + /// 's doc comment for the + /// retail citation). acdream has no present-time frame stretch to hang that + /// on, so the equivalent has to live one step earlier, at the source texture: + /// while is scaling the composed quads + /// themselves, this method gives a nearest handle a same-pixels twin sampled + /// LINEAR instead, so the stretch softens the way retail's frame blit did + /// rather than aliasing. + /// + /// + /// + /// Returns UNCHANGED for anything this cache never + /// registered nearest — chrome/background art already samples + /// (linear) and has nothing to + /// swap, and (DrawFill's untextured + /// branch) is not a texture at all. Callers do not need to know which case + /// they're in: this is a cheap dictionary probe either way, so + /// can call it unconditionally whenever + /// the canvas is scaled. + /// + /// + /// + /// The twin reuses the ORIGINAL — no re-decode, no + /// second upload, no additional bytes tracked in the memory ledger — and + /// occupies one more device texture-table slot, exactly the shape + /// 's (surface, wrap) keying already uses to + /// register one texture under two samplers. Lazy: a session that never + /// activates a fixed-canvas screen never creates one. + /// + /// + internal uint GetOrCreateLinearUiTwin(uint handle) + { + if (!_nearestUiTextureSources.TryGetValue(handle, out IGpuTexture? texture)) + return handle; + if (_linearUiTwinHandles.TryGetValue(handle, out uint twin)) + return twin; + + IGpuSampler linearSampler = _device.CreateSampler(GpuSamplerDescription.WorldRepeat); + GpuTextureSlot twinSlot = _device.RegisterTexture(texture, linearSampler); + uint twinHandle = UiTextureTableHandle.FromSlot(twinSlot); + _linearUiTwinHandles[handle] = twinHandle; + return twinHandle; + } + /// /// The identity a UI upload is accounted under. There is no GL name on the /// Vulkan-only backend, so a descending synthetic counter supplies one; the @@ -994,6 +1070,15 @@ public sealed class TextureCache _paletteIndexedByTexture.Clear(); + // Campaign LA gate round 2 (AD-98): linear twin slots. Each one is a + // SECOND table registration of a texture another family below owns and + // disposes — release the slot here, before that texture goes away, and + // never touch the texture itself (that would double-dispose it). + foreach (uint twinHandle in _linearUiTwinHandles.Values) + _device.ReleaseTextureSlot(UiTextureTableHandle.ToSlot(twinHandle)); + _linearUiTwinHandles.Clear(); + _nearestUiTextureSources.Clear(); + // RenderSurface (UI sprite) textures — Campaign V slice V4a: each // entry's IGpuTexture.Dispose() releases the underlying GL name // through the device's own retirement queue, so only the memory- diff --git a/tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs b/tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs new file mode 100644 index 00000000..89402a52 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs @@ -0,0 +1,106 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign LA gate round 2 (register AD-98 filtering fidelity, 2026-08-15): +/// pins 's texture-swap seam — +/// , consulted only while +/// is not — the +/// half of the fix that doesn't need a live GPU. The user-visible symptom this +/// answers: the fixed-canvas char-select stretch (73041d70) reported JAGGED +/// text, because dat-font glyph atlases upload nearest (pixel-exact at 1:1) and +/// stayed nearest even while every quad was being magnified 2.4×1.8. The actual +/// linear-twin CREATION lives in TextureCache.GetOrCreateLinearUiTwin +/// (GPU-backed, see TextureCacheLinearTwinTests); this file proves the +/// RENDERER SIDE of the seam — segment handle selection — using a fake resolver +/// so the assertion doesn't depend on TextureCache's own wiring being correct. +/// +public sealed class TextRendererLinearTwinTests +{ + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static TextRenderer BuildRenderer() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + return renderer; + } + + [Fact] + public void CanvasScaleOne_DrawSprite_NeverConsultsResolver_KeepsOriginalHandle() + { + // At the native 1:1 scale (every in-world/UI frame today) the resolver + // must not even be CALLED, not just "called and return the same value" — + // a resolver that throws proves the ordinary path pays zero overhead for + // a feature it never activates, exactly the "lazy" design constraint the + // Campaign LA round-2 follow-up was scoped to. + TextRenderer renderer = BuildRenderer(); + renderer.LinearTwinResolver = _ => throw new System.InvalidOperationException( + "LinearTwinResolver must not be consulted while CanvasScale == One."); + + renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(5u, seg.Texture); + } + + [Fact] + public void CanvasScaleNotOne_DrawSprite_SwapsHandleThroughResolver() + { + // The fixed-canvas case: CanvasScale is set by UiRoot.Draw for the + // duration of a fixed-canvas screen's tree. A nearest-registered dat-font + // glyph handle (5) must draw through its linear twin (999), not itself. + TextRenderer renderer = BuildRenderer(); + renderer.CanvasScale = new Vector2(2.4f, 1.8f); + renderer.LinearTwinResolver = handle => handle == 5u ? 999u : handle; + + renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(999u, seg.Texture); + } + + [Fact] + public void CanvasScaleNotOne_ResolverIsIdentityForUnknownHandles() + { + // Chrome/background art (never registered nearest) and + // UiTextureTableHandle.None (DrawFill's untextured branch) are the + // majority of scaled-canvas draws. TextureCache.GetOrCreateLinearUiTwin + // returns them unchanged; this pins that TextRenderer forwards whatever + // the resolver returns without a separate "was this swapped" branch. + TextRenderer renderer = BuildRenderer(); + renderer.CanvasScale = new Vector2(2.4f, 1.8f); + renderer.LinearTwinResolver = handle => handle == 5u ? 999u : handle; + + renderer.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(7u, seg.Texture); + } + + [Fact] + public void CanvasScaleNotOne_NoResolverWired_KeepsOriginalHandle_DoesNotThrow() + { + // A host/test that never wires LinearTwinResolver (every existing + // TextRenderer construction site before this change, and any future + // test double) must keep drawing — nearest stays nearest, the + // pre-existing (if jagged) behavior, rather than a null-reference + // failure the moment a fixed-canvas screen activates. + TextRenderer renderer = BuildRenderer(); + renderer.CanvasScale = new Vector2(2.4f, 1.8f); + + renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(5u, seg.Texture); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs b/tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs new file mode 100644 index 00000000..d960d25a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs @@ -0,0 +1,124 @@ +using System.Linq; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign LA gate round 2 (register AD-98 filtering fidelity, 2026-08-15): +/// pins — the GPU-table half +/// of the fixed-canvas jagged-text fix (TextRendererLinearTwinTests pins +/// the renderer-side handle swap this method feeds). +/// +/// +/// Uses rather than +/// to get a nearest-sampled +/// handle without any DAT fixture — UploadRgba8 never touches the injected +/// IDatReaderWriter, so the simple TextureCache(device, dats) +/// constructor can run with / +/// and no live GPU, matching this suite's existing renderer-test-double idiom. +/// +/// +public sealed class TextureCacheLinearTwinTests +{ + private static (RecordingGpuDevice device, TextureCache cache) Build() + { + var device = new RecordingGpuDevice(); + // UploadRgba8/GetOrCreateLinearUiTwin/Dispose never touch the dats + // reference — see the class doc comment. + var cache = new TextureCache(device, dats: null!); + // RecordingGpuDevice's own constructor registers a 1x1 default-white + // placeholder (DefaultTextureSlot) — clear that registration out of the + // recorded call log so each test's assertions only see the + // registrations IT caused. Clear() only wipes the call log, not the + // slot/sampler state, so DefaultTextureSlot itself is untouched. + device.Clear(); + return (device, cache); + } + + [Fact] + public void UnknownHandle_ReturnsUnchanged() + { + // Chrome/background art (never registered nearest) and any handle this + // cache never saw (including UiTextureTableHandle.None == 0) pass + // through untouched — there is nothing to swap. + (_, TextureCache cache) = Build(); + + Assert.Equal(0u, cache.GetOrCreateLinearUiTwin(0u)); + Assert.Equal(12345u, cache.GetOrCreateLinearUiTwin(12345u)); + } + + [Fact] + public void NearestHandle_GetsADifferentTwinHandle_SampledLinear() + { + (RecordingGpuDevice device, TextureCache cache) = Build(); + byte[] rgba = new byte[4 * 4 * 4]; + uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true); + + uint twinHandle = cache.GetOrCreateLinearUiTwin(nearestHandle); + + Assert.NotEqual(0u, twinHandle); + Assert.NotEqual(nearestHandle, twinHandle); + + // The SECOND registration recorded against the device (the first is the + // original nearest upload) must carry a linear filter — the whole point + // of the twin. + var registrations = device.OfKind().ToList(); + Assert.Equal(2, registrations.Count); + Assert.Equal(GpuFilter.Nearest, registrations[0].Sampler.MinFilter); + Assert.Equal(GpuFilter.Linear, registrations[1].Sampler.MinFilter); + Assert.Equal(GpuFilter.Linear, registrations[1].Sampler.MagFilter); + + // Both registrations name the SAME underlying texture — the twin reuses + // the original decoded pixels rather than re-uploading. + Assert.Equal(registrations[0].TextureName, registrations[1].TextureName); + } + + [Fact] + public void NearestHandle_RepeatedRequest_ReturnsTheSameCachedTwin() + { + (RecordingGpuDevice device, TextureCache cache) = Build(); + byte[] rgba = new byte[4 * 4 * 4]; + uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true); + + uint first = cache.GetOrCreateLinearUiTwin(nearestHandle); + uint second = cache.GetOrCreateLinearUiTwin(nearestHandle); + + Assert.Equal(first, second); + // Exactly one twin registration — the second request must not create + // another device-table slot. + Assert.Equal(2, device.OfKind().Count()); + } + + [Fact] + public void NonNearestUpload_HasNoTwin() + { + // UploadRgba8's default (nearest: false) mirrors chrome/background art: + // it already samples GpuSamplerDescription.WorldRepeat (linear), so it + // never enters the nearest-source table and GetOrCreateLinearUiTwin + // hands the same handle straight back. + (_, TextureCache cache) = Build(); + byte[] rgba = new byte[4 * 4 * 4]; + uint linearHandle = cache.UploadRgba8(rgba, 4, 4, nearest: false); + + Assert.Equal(linearHandle, cache.GetOrCreateLinearUiTwin(linearHandle)); + } + + [Fact] + public void Dispose_ReleasesTheTwinSlot() + { + (RecordingGpuDevice device, TextureCache cache) = Build(); + byte[] rgba = new byte[4 * 4 * 4]; + uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true); + uint twinHandle = cache.GetOrCreateLinearUiTwin(nearestHandle); + GpuTextureSlot twinSlot = UiTextureTableHandle.ToSlot(twinHandle); + + cache.Dispose(); + + Assert.Contains( + device.OfKind(), + release => release.Slot == twinSlot.Index); + } +} From ef96c5548982cd988d5d06168dd75373b4e9e9dd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 11:50:45 +0200 Subject: [PATCH 070/138] =?UTF-8?q?fix(ui,net):=20Campaign=20LA=20gate=20r?= =?UTF-8?q?ound=202=20=E2=80=94=20char-select=20exit=20confirmation,=20aut?= =?UTF-8?q?hored=20row=20justify,=20world=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (Exit button dead): retail's gmCharacterManagementUI Exit button (element 0x100003A4, offset 7 from the listbox base in ListenToElementMessage@0x004ed5a0) opens MakeConfirmExitDialog (0x004ed250), whose exact ID_CharacterManagement_ConfirmExit text (table 0x23000002) and m_confirmExitDialogContext re-entry guard are now ported. On confirm (matching RecvNotice_CloseDialog@0x004ed760 case 1's ConfirmationResult check) the client exits through the EXISTING graceful window-close seam (CharacterSelectionRuntimeBindings .RequestExit -> d.Window.Close, the same delegate GameplayInputCommandController's Escape fallback already uses) so disconnected/exited status events still fire via GameWindow.OnClosing -> CompleteShutdown. Retail's real post-confirm destination is QueueUIMode(0x10000009) -> gmEpilogueUI, an epilogue screen this round does not port — recorded as AD-99. Credits (element 0x100003A3, QueueUIMode(0x10000005) -> gmCreditsUI) stays visibly ghosted like Create, same treatment, out of scope this round. Finding 2 (row names center-aligned, retail is left): the character row template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT confirmed HJustify=Left with three stateful Type-3 highlight-art children and no Type-12 caption child) authors its OWN justify directly, with no separate text child to lift a label from. DatWidgetFactory.BuildButton's Left-justify branch required !ReferenceEquals(labelInfo, info) — true only when a label was LIFTED from a distinct child — so a button's own direct HJustify=Left was silently dropped to UiButton's Center default. Widened the branch to also honor the direct case, preserving the existing lifted-child LabelOffsetX behavior and leaving genuinely-centered buttons (CREATE/ENTER/DELETE/RESTORE) untouched. Finding 3 (World box empty): parsed ACE's GameMessageServerName (opcode 0xF7E1, ACE.Server/Network/GameMessages/Messages/ GameMessageServerName.cs; retail CM_Login::DispatchUI_WorldInfo @0x006ad860 -> ClientUISystem::Handle_Login__WorldInfo@0x005641a0 -> ECM_Login::SendNotice_WorldName@0x00692b10, notice 0x186a2, consumed by gmCharacterManagementUI::UpdateWorldName@0x004ec120 / RecvNotice_WorldName@0x004ec360 onto element 0x1000039B) as src/AcDream.Core.Net/Messages/ServerName.cs, cross-checked against holtburger's ServerNameData. WorldSession.ServerNameReceived fires alongside CharacterListReceived (ACE sends both in one SendConnectResponse batch); RuntimeCharacterSelectionState. ApplyWorldName is the new J-owner field (ungated by lifecycle, since either message can arrive first); CharacterManagementUiController binds it onto the WorldTextElementId UiText. Per the LA1 status vocabulary, the characterList STATUS event's worldName field is intentionally NOT added this round (kept bounded to the client-side fix) — a follow-up if the launcher UI wants it. Also corrects AD-44, discovered stale while filing AD-99: its opening claim ("acdream has no retained character-management screen") was false as of this session — LA7/LA8 shipped the screen in earlier commits without updating this row. Tests: exit-confirm open/cancel/confirm/re-entry-guard flow; DatWidgetFactory own-HJustify-Left/Center regression tests plus the live-DAT pinned row-justify assertion; ServerName parse round-trip (byte-exact vs ACE's AceWireWriter fixture, truncation/wrong-opcode cases); WorldSession dispatch test (roster+world in one wire batch); RuntimeCharacterSelectionState.ApplyWorldName tests (order-independent of ApplyRoster, unchanged-value no-op, Reset clears); controller test binding the World text element to the live snapshot. Extended the shared RetailDialogFactoryTests.BuildDialogLayout test fixture with a Confirmation-type branch (Accept/Reject buttons) since this is its first RetailDialogType.Confirmation consumer. Suites: full solution Release build green; AcDream.App.Tests 5100/6 skips, AcDream.Core.Net.Tests 965/0, AcDream.Runtime.Tests 1665/0, all Release, 0 failures; live-DAT probes (ACDREAM_PROBE_LIVE_MOUNT=1) green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 5 +- .../InteractionRetainedUiComposition.cs | 8 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 1 + .../Layout/CharacterManagementUiController.cs | 91 ++++++++++++- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 18 ++- src/AcDream.App/UI/RetailUiRuntime.cs | 32 ++++- src/AcDream.Core.Net/Messages/ServerName.cs | 90 +++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 29 ++++ .../Session/LiveSessionController.cs | 15 ++- .../Session/RuntimeCharacterSelectionState.cs | 31 +++++ .../InteractionUiRuntimeSourcesTests.cs | 1 + .../Layout/CharacterManagementLiveDatTests.cs | 35 +++++ .../CharacterManagementUiControllerTests.cs | 127 +++++++++++++++++- .../UI/Layout/DatWidgetFactoryTests.cs | 61 +++++++++ .../UI/Layout/RetailDialogFactoryTests.cs | 26 ++++ .../Messages/ServerNameTests.cs | 81 +++++++++++ .../WorldSessionCharacterSelectionTests.cs | 37 +++++ .../RuntimeCharacterSelectionStateTests.cs | 49 +++++++ 18 files changed, 724 insertions(+), 13 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/ServerName.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6e05a0c9..01fc2d58 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 74 active rows (AD-98 filed 2026-08-15 at Campaign LA gate round 2 — the char-select root's own background stretches instead of tiling by resizing the mounted root element to the live viewport and marking its background quad UV 0..1, substituting for retail's fixed-800x600-canvas-stretched-at-presentation mechanism which acdream's live-resolution render pipeline has no analogue for; AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 75 active rows (AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2 — the char-select root's own background stretches instead of tiling by resizing the mounted root element to the live viewport and marking its background quad UV 0..1, substituting for retail's fixed-800x600-canvas-stretched-at-presentation mechanism which acdream's live-resolution render pipeline has no analogue for; AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -151,7 +151,7 @@ readiness/requeue adaptation. See | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | -| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | +| AD-44 | **NARROWED 2026-08-15 at Campaign LA gate round 2 (staleness caught while filing AD-99) — the opening clause was WRONG as of this session: Campaign LA's LA7/LA8 slices (landed in earlier commits on this branch) shipped a real retained `gmCharacterManagementUI`-authored character-select screen (`CharacterManagementUiController`, `RuntimeCharacterSelectionState`), and no register row was updated when they did.** What remains true: `TrySelectFirstAvailable` still deterministically picks the first active, non-greyed identity, but ONLY for headless/no-selector sessions and probe connects (LA7's no-selector flow) — a graphical session without a character selector now stops at the retained selection screen instead of auto-entering. Native-window close still performs retail's complete character-logoff handshake plus transport disconnect instead of returning to character selection; there remains no in-client path from in-world back to a live character-select screen (AD-99 documents the adjacent Exit-button gap: the screen's OWN Exit button now exists and confirms, but also closes the client rather than returning to selection). One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.Runtime/Session/LiveSessionController.cs` (`StartCore`'s `AwaitCharacterSelection` branch); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (the retained screen); `src/AcDream.Core.Net/WorldSession.cs` (`Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | Headless/probe sessions still need unattended selection (no UI to select from) — the deterministic fallback remains correct THERE. A full in-client "log off character, return to selection" flow is separate session/wire work no slice has scoped yet. | A headless/probe account with multiple playable characters still enters the first wire-order identity without an explicit choice (by design — no UI exists in that host). An eventual in-client "log off character" action still cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management — the graphical screen exists now, but nothing feeds it from an in-world state. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | @@ -192,6 +192,7 @@ readiness/requeue adaptation. See | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored` | | 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-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) | --- diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index b54b1f67..6673cdf9 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -955,7 +955,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.CharacterSelectionRequestDelete, late.GameRuntime.CharacterSelectionConfirmDelete, late.GameRuntime.CharacterSelectionRestore, - late.GameRuntime.CharacterSelectionCancel) + late.GameRuntime.CharacterSelectionCancel, + // Campaign LA gate round 2 finding 1: the SAME + // window-close path GameplayInputCommandController's + // Escape fallback uses (IGameplayWindowCommands.Close + // /GameplayWindowCommands wrap this same d.Window.Close + // delegate) — no separate exit path. + d.Window.Close) : null); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 6e656da7..930e8412 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -181,6 +181,7 @@ internal sealed class CurrentGameRuntimeAdapter AccountName: string.Empty, SlotCount: 0, RosterCount: 0, + WorldName: string.Empty, HighlightedCharacterId: 0u, HighlightedDisplayIndex: -1, PendingDeleteCharacterId: 0u, diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index bc242a25..eb2c7c8c 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -13,25 +13,46 @@ internal sealed class CharacterManagementUiController : IDisposable { internal const uint RootEnum = 0x10000005u; internal const uint RootElementId = 0x1000039Au; + internal const uint WorldTextElementId = 0x1000039Bu; internal const uint ListElementId = 0x1000039Du; internal const uint CreateElementId = 0x100003A0u; internal const uint EnterElementId = 0x100003A2u; internal const uint DeleteElementId = 0x1000039Fu; internal const uint RestoreElementId = 0x1000039Eu; + /// + /// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id + /// switch is keyed off idElement - 0x1000039d (the listbox base); + /// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers + /// (Register@0x0047a69e) — out of scope this round (finding 1 note). + /// + internal const uint CreditsElementId = 0x100003A3u; + /// Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250. + internal const uint ExitElementId = 0x100003A4u; internal sealed record DialogStrings( Func DeleteConfirmation, string DeleteResponse, string PleaseWait, - string EnteringWorld); + string EnteringWorld, + /// + /// Retail ID_CharacterManagement_ConfirmExit (table + /// 0x23000002) — "Are you sure you want to leave?", the text + /// MakeConfirmExitDialog@0x004ed250 resolves via + /// StringInfo::SetStringIDandTableEnum(compute_str_hash( + /// "ID_CharacterManagement_ConfirmExit"), 0x10000002). + /// + string ConfirmExit); private readonly UiRoot _host; private readonly ImportedLayout _layout; + private readonly UiText _worldText; private readonly UiTemplateListBox _list; private readonly UiButton _create; private readonly UiButton _enter; private readonly UiButton _delete; private readonly UiButton _restore; + private readonly UiButton _credits; + private readonly UiButton _exit; private readonly RetailDialogFactory _dialogs; private readonly CharacterSelectionRuntimeBindings _bindings; private readonly DialogStrings _strings; @@ -41,10 +62,12 @@ internal sealed class CharacterManagementUiController : IDisposable private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; private long _lastRevision = long.MinValue; + private string _lastWorldName = string.Empty; private uint _deleteDialogContext; private uint _operationWaitContext; private uint _enterWaitContext; private uint _errorDialogContext; + private uint _confirmExitDialogContext; private bool _active; private bool _restoreCommandInFlight; private bool _suppressDialogCallbacks; @@ -53,22 +76,28 @@ internal sealed class CharacterManagementUiController : IDisposable private CharacterManagementUiController( UiRoot host, ImportedLayout layout, + UiText worldText, UiTemplateListBox list, UiButton create, UiButton enter, UiButton delete, UiButton restore, + UiButton credits, + UiButton exit, RetailDialogFactory dialogs, CharacterSelectionRuntimeBindings bindings, DialogStrings strings) { _host = host; _layout = layout; + _worldText = worldText; _list = list; _create = create; _enter = enter; _delete = delete; _restore = restore; + _credits = credits; + _exit = exit; _dialogs = dialogs; _bindings = bindings; _strings = strings; @@ -101,6 +130,22 @@ internal sealed class CharacterManagementUiController : IDisposable _enter.OnClick = EnterSelected; _delete.OnClick = RequestDelete; _restore.OnClick = RestoreSelected; + + // Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of + // scope this round (finding 1 note) — same "future campaign, visibly + // ghosted, no invented action" treatment as Create above. Filed as + // issue #397. + _credits.Visible = true; + _credits.Enabled = false; + _credits.OnClick = null; + _exit.OnClick = RequestExit; + + // World name (retail UpdateWorldName@0x004ec120 / + // RecvNotice_WorldName@0x004ec360 both just push + // Client::GetWorldName() onto this element). LinesProvider reads the + // live field Tick() updates each time Runtime's snapshot changes. + _worldText.LinesProvider = + () => [new UiText.Line(_lastWorldName, _worldText.DefaultColor)]; } internal UiElement Root => _layout.Root; @@ -109,6 +154,7 @@ internal sealed class CharacterManagementUiController : IDisposable internal uint OperationWaitContext => _operationWaitContext; internal uint EnterWaitContext => _enterWaitContext; internal uint ErrorDialogContext => _errorDialogContext; + internal uint ConfirmExitDialogContext => _confirmExitDialogContext; internal void ResetSession() { @@ -171,11 +217,14 @@ internal sealed class CharacterManagementUiController : IDisposable } if (layout.Root.DatElementId != RootElementId + || layout.FindElement(WorldTextElementId) is not UiText worldText || layout.FindElement(ListElementId) is not UiTemplateListBox list || layout.FindElement(CreateElementId) is not UiButton create || layout.FindElement(EnterElementId) is not UiButton enter || layout.FindElement(DeleteElementId) is not UiButton delete - || layout.FindElement(RestoreElementId) is not UiButton restore) + || layout.FindElement(RestoreElementId) is not UiButton restore + || layout.FindElement(CreditsElementId) is not UiButton credits + || layout.FindElement(ExitElementId) is not UiButton exit) { Console.WriteLine( "[UI] character management: the authored root/list/button contract is incomplete."); @@ -188,11 +237,14 @@ internal sealed class CharacterManagementUiController : IDisposable return new CharacterManagementUiController( host, layout, + worldText, list, create, enter, delete, restore, + credits, + exit, dialogs, bindings, strings); @@ -204,6 +256,7 @@ internal sealed class CharacterManagementUiController : IDisposable enter.OnClick = null; delete.OnClick = null; restore.OnClick = null; + exit.OnClick = null; throw; } } @@ -249,6 +302,11 @@ internal sealed class CharacterManagementUiController : IDisposable _host.BringToFront(Root); } + // World name rides independently of the roster revision gate below — + // ServerName can arrive slightly before or after CharacterList (see + // RuntimeCharacterSelectionState.ApplyWorldName). + _lastWorldName = snapshot.WorldName; + if (_lastGeneration != snapshot.Generation || _lastRevision != snapshot.Revision) { @@ -314,6 +372,7 @@ internal sealed class CharacterManagementUiController : IDisposable _enter.OnClick = null; _delete.OnClick = null; _restore.OnClick = null; + _exit.OnClick = null; foreach (UiButton row in _rows) { row.OnClick = null; @@ -550,6 +609,33 @@ internal sealed class CharacterManagementUiController : IDisposable InvalidateAndTick(); } + private void RequestExit() + { + if (_disposed) + return; + + // MakeConfirmExitDialog @ 0x004ed250's own guard: a second Exit + // click while the confirmation is already open is a no-op. + if (_confirmExitDialogContext != 0u) + return; + + _confirmExitDialogContext = _dialogs.MakeConfirmation( + _strings.ConfirmExit, + data => + { + _confirmExitDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + // RecvNotice_CloseDialog @ 0x004ed760 case 1: only a + // confirmed (OK) close proceeds through the SAME graceful + // shutdown path window-close uses; Cancel leaves the screen + // exactly as it was. + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + _bindings.RequestExit(); + }); + } + private void ReconcileDialogs( IRuntimeCharacterSelectionView view, RuntimeCharacterSelectionSnapshot snapshot) @@ -689,6 +775,7 @@ internal sealed class CharacterManagementUiController : IDisposable CloseContext(ref _operationWaitContext, suppressCallback: false); CloseContext(ref _enterWaitContext, suppressCallback: false); CloseContext(ref _errorDialogContext, suppressCallback: false); + CloseContext(ref _confirmExitDialogContext, suppressCallback: false); } finally { diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 5991ba57..eb74843d 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -881,10 +881,24 @@ public static class DatWidgetFactory button.LabelAlign = UiButton.LabelAlignment.Left; button.LabelOffsetX = face.X + face.Width + 4f; } - else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left) + else if (labelInfo.HJustify == HJustify.Left) { + // Campaign LA gate round 2 finding 2: the guard used to require + // labelInfo to be a LIFTED Type-12 text child (!ReferenceEquals), + // so a button authoring its OWN HJustify=Left with no separate + // label child — e.g. gmCharacterManagementUI's character-list row + // template (0x21000004/0x100003A5: HJustify=Left, three stateful + // Type-3 highlight-art children, no Type-12 caption child) — fell + // through with LabelAlign left at UiButton's Center default. + // Live-DAT probe confirmed: rowInfo.HJustify=Left, + // authoredFaces.Length=3 (faceSegments, not a single face), no + // Type-12 child, and the built row's LabelAlign came out Center. + // labelInfo.X is only a valid inner-offset when a distinct child + // was actually lifted; for the direct (labelInfo == info) case, + // leave UiButton's own default 3px LabelOffsetX in place. button.LabelAlign = UiButton.LabelAlignment.Left; - button.LabelOffsetX = labelInfo.X; + if (!ReferenceEquals(labelInfo, info)) + button.LabelOffsetX = labelInfo.X; } return button; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index d8253c91..91c559c9 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -376,6 +376,20 @@ public sealed record KeyboardRuntimeBindings( /// mirror; an absent view means the current adapter has not bound (or has /// already been released). /// +/// +/// Campaign LA gate round 2 finding 1: retail's Exit button +/// (gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0, +/// element offset 7 from the listbox base — id 0x100003A4) opens +/// MakeConfirmExitDialog@0x004ed250; on confirm +/// (RecvNotice_CloseDialog@0x004ed760 case 1) retail queues UI mode +/// 0x10000009 (gmEpilogueUI) rather than exiting immediately — +/// out of scope here. This is a plain host action, not a generation-gated +/// Runtime command: it is the SAME window-close path +/// GameplayWindowCommands/IGameplayWindowCommands.Close already +/// use for the in-world Escape fallback (d.Window.Close at +/// composition), so status events disconnected/exited still +/// fire through GameWindow.OnClosingCompleteShutdown. +/// public sealed record CharacterSelectionRuntimeBindings( Func View, Func Highlight, @@ -383,7 +397,8 @@ public sealed record CharacterSelectionRuntimeBindings( Func RequestDelete, Func ConfirmDelete, Func Restore, - Func Cancel); + Func Cancel, + Action RequestExit); public sealed record RetailUiRuntimeBindings( UiHost Host, @@ -3766,6 +3781,7 @@ public sealed class RetailUiRuntime : IDisposable string? deleteConfirmationProbe; string? pleaseWait; string? enteringWorld; + string? confirmExit; lock (_bindings.Assets.DatLock) { deleteConfirmationProbe = strings.ResolveTemplate( @@ -3787,12 +3803,21 @@ public sealed class RetailUiRuntime : IDisposable strings, stringTableId, "ID_Character_EnteringWorld"); + // Finding 1: MakeConfirmExitDialog@0x004ed250 resolves this via + // compute_str_hash("ID_CharacterManagement_ConfirmExit") against + // the same table-enum-0x10000002 -> 0x23000002 the other + // character-management dialogs already use. + confirmExit = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharacterManagement_ConfirmExit"); } if (deleteConfirmationProbe is null || deleteResponse is null || pleaseWait is null - || enteringWorld is null) + || enteringWorld is null + || confirmExit is null) { Console.WriteLine( "[UI] character management: required retail strings are unavailable."); @@ -3835,7 +3860,8 @@ public sealed class RetailUiRuntime : IDisposable ComposeDeleteConfirmation, deleteResponse, pleaseWait, - enteringWorld)); + enteringWorld, + confirmExit)); } private static string? ResolveCharacterManagementString( diff --git a/src/AcDream.Core.Net/Messages/ServerName.cs b/src/AcDream.Core.Net/Messages/ServerName.cs new file mode 100644 index 00000000..57a39469 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ServerName.cs @@ -0,0 +1,90 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound ServerName GameMessage (opcode 0xF7E1). ACE sends +/// this in the SAME batch as , right after +/// AuthConnectResponse completes — it is the world (server) name the +/// retail character-select screen's "World" box shows. +/// +/// +/// Retail wire path: CM_Login::DispatchUI_WorldInfo@0x006ad860 checks +/// the leading opcode against 0xf7e1, unpacks the trailing +/// PStringBase<char>, and calls +/// ClientUISystem::Handle_Login__WorldInfo@0x005641a0(currentConnections, +/// maxConnections, worldName), which forwards only the name to +/// ECM_Login::SendNotice_WorldName@0x00692b10 (notice id +/// 0x186a2). gmCharacterManagementUI registers for that notice +/// in its ctor (0x004ec8f0) and both +/// RecvNotice_WorldName@0x004ec360 and its own +/// UpdateWorldName@0x004ec120 resolve child element 0x1000039B +/// (UIElement::GetChildRecursive(m_rootField, 0x1000039b), dynamic-cast +/// to UIElement_Text) and call +/// UIElement_Text::SetText(Client::GetInstance()->GetWorldName()) — +/// Client::GetWorldName@0x00401ca0/SetWorldName@0x00402090 just +/// hold the string the notice delivered. The two leading dwords +/// (currentConnections/maxConnections) are read off the wire by the +/// dispatcher but never consumed by the character-management screen itself. +/// +/// +/// +/// ACE: GameMessageOpcode.ServerName = 0xF7E1 +/// (ACE.Server/Network/GameMessages/GameMessageOpcode.cs); +/// GameMessageServerName +/// (ACE.Server/Network/GameMessages/Messages/GameMessageServerName.cs) +/// writes i32 currentConnections, i32 maxConnections, String16L +/// serverName; sent from +/// AuthenticationHandler.SendConnectResponse +/// (ACE.Server/Network/Handlers/AuthenticationHandler.cs:258) +/// alongside GameMessageCharacterList and +/// GameMessageDDDInterrogation. holtburger's +/// ServerNameData +/// (holtburger-protocol/src/messages/character/types.rs) parses the +/// same three fields and cross-checks the field order/types. +/// +/// +/// +/// u32 opcode (0xF7E1) +/// i32 currentConnections +/// i32 maxConnections +/// String16L worldName +/// +/// +public static class ServerName +{ + public const uint Opcode = 0xF7E1u; + + public readonly record struct Parsed( + int CurrentConnections, + int MaxConnections, + string WorldName); + + /// + /// Parse a ServerName body. must start with the + /// 4-byte opcode (0xF7E1) — i.e. pass the full reassembled GameMessage + /// output from . + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != Opcode) + throw new FormatException($"expected ServerName opcode 0x{Opcode:X4}, got 0x{opcode:X8}"); + + int currentConnections = unchecked((int)ReadU32(body, ref pos)); + int maxConnections = unchecked((int)ReadU32(body, ref pos)); + string worldName = StringReader.ReadString16L(body, ref pos); + + return new Parsed(currentConnections, maxConnections, worldName); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 8cfab9f5..04d4397f 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -599,6 +599,12 @@ public sealed class WorldSession : IDisposable public event Action? CharacterDeleteAcknowledged; public event Action? CharacterRestoreReceived; public event Action? CharacterErrorReceived; + /// + /// Campaign LA gate round 2 finding 3: ACE sends this in the same batch + /// as (right after + /// AuthConnectResponse) — see . + /// + public event Action? ServerNameReceived; /// /// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode @@ -691,6 +697,13 @@ public sealed class WorldSession : IDisposable } public CharacterList.Parsed? Characters { get; private set; } + + /// + /// Campaign LA gate round 2 finding 3: last + /// (opcode 0xF7E1) received, mirroring ' shape — + /// ACE sends it in the same batch, right after AuthConnectResponse. + /// + public ServerName.Parsed? ServerInfo { get; private set; } private CharacterError.Parsed? _lastCharacterSelectionError; private readonly IWorldSessionTransport _net; @@ -1789,6 +1802,22 @@ public sealed class WorldSession : IDisposable Characters = parsed; CharacterListReceived?.Invoke(parsed); } + else if (op == ServerName.Opcode) + { + ServerName.Parsed parsed; + try + { + parsed = ServerName.Parse(body); + } + catch + { + // Malformed management messages do not poison the + // remaining ordered UIQueue fragments. + continue; + } + ServerInfo = parsed; + ServerNameReceived?.Invoke(parsed); + } else if (op == CharacterDelete.Opcode && CharacterDelete.IsAcknowledgement(body)) { diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 8dae81a4..3cdc3413 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -246,23 +246,27 @@ public sealed class LiveSessionController private readonly Action _delete; private readonly Action _restore; private readonly Action _error; + private readonly Action _worldName; public CharacterSelectionWireBinding( WorldSession session, Action roster, Action delete, Action restore, - Action error) + Action error, + Action worldName) { _session = session; _roster = roster; _delete = delete; _restore = restore; _error = error; + _worldName = worldName; session.CharacterListReceived += roster; session.CharacterDeleteAcknowledged += delete; session.CharacterRestoreReceived += restore; session.CharacterErrorReceived += error; + session.ServerNameReceived += worldName; } public bool IsDisposed => _session is null; @@ -276,6 +280,7 @@ public sealed class LiveSessionController session.CharacterDeleteAcknowledged -= _delete; session.CharacterRestoreReceived -= _restore; session.CharacterErrorReceived -= _error; + session.ServerNameReceived -= _worldName; } } @@ -886,6 +891,14 @@ public sealed class LiveSessionController if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyError(error); } + }, + worldName => + { + lock (_gate) + { + if (IsCurrent(scope, generation)) + CharacterSelectionState.ApplyWorldName(worldName.WorldName); + } }); public RuntimeCommandResult Highlight( diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index 18bfbc93..eb6a07a1 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -36,6 +36,11 @@ public enum RuntimeCharacterSelectionDeltaKind ErrorChanged, EnteringWorld, EnteredWorld, + /// + /// Campaign LA gate round 2 finding 3: arrived — + /// see . + /// + WorldNameChanged, } public readonly record struct RuntimeCharacterSelectionEntry( @@ -77,6 +82,7 @@ public readonly record struct RuntimeCharacterSelectionSnapshot( string AccountName, int SlotCount, int RosterCount, + string WorldName, uint HighlightedCharacterId, int HighlightedDisplayIndex, uint PendingDeleteCharacterId, @@ -202,6 +208,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private long _revision; private string _accountName = string.Empty; private int _slotCount; + private string _worldName = string.Empty; private uint _highlightedCharacterId; private uint _pendingDeleteCharacterId; private uint _lastRestoreRequestedCharacterId; @@ -236,6 +243,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _accountName, _slotCount, _entries.Length, + _worldName, _highlightedCharacterId, selectedIndex, _pendingDeleteCharacterId, @@ -326,6 +334,28 @@ public sealed class RuntimeCharacterSelectionState : IDisposable selected); } + /// + /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName + /// (0x004ec120) / RecvNotice_WorldName (0x004ec360) + /// both just push Client::GetWorldName() onto element + /// 0x1000039B — no lifecycle gate. ACE sends ServerName in + /// the same batch as CharacterList, so this may land slightly + /// before or after ; it is intentionally + /// ungated (beyond disposal) so neither arrival order loses the name. + /// + internal void ApplyWorldName(string worldName) + { + ArgumentNullException.ThrowIfNull(worldName); + lock (_gate) + { + if (_disposed || _worldName == worldName) + return; + _worldName = worldName; + _revision++; + } + Publish(RuntimeCharacterSelectionDeltaKind.WorldNameChanged); + } + internal bool TryHighlight(uint characterId) { lock (_gate) @@ -900,6 +930,7 @@ public sealed class RuntimeCharacterSelectionState : IDisposable _entries = []; _accountName = string.Empty; _slotCount = 0; + _worldName = string.Empty; _highlightedCharacterId = 0u; _pendingDeleteCharacterId = 0u; _lastRestoreRequestedCharacterId = 0u; diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index c9318931..8017787d 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -308,6 +308,7 @@ public sealed class InteractionUiRuntimeSourcesTests AccountName: "account", SlotCount: 0, RosterCount: 0, + WorldName: string.Empty, HighlightedCharacterId: 0u, HighlightedDisplayIndex: -1, PendingDeleteCharacterId: 0u, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 4a0e911a..500ca6ba 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -67,6 +67,18 @@ public sealed class CharacterManagementLiveDatTests "DELETE"); AssertButton(screen, CharacterManagementUiController.RestoreElementId, "RESTORE"); + // Finding 1: retail's bottom-row Credits/Exit buttons (offsets 6/7 + // from the listbox base in + // gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0). + AssertButton(screen, CharacterManagementUiController.CreditsElementId, + "CREDITS"); + AssertButton(screen, CharacterManagementUiController.ExitElementId, + "EXIT"); + // Finding 3: the World box (retail element 0x1000039B, resolved via + // UpdateWorldName@0x004ec120) imports as a plain UiText the + // controller binds Runtime's ServerName-sourced snapshot field to. + Assert.IsType(screen.FindElement( + CharacterManagementUiController.WorldTextElementId)); Assert.DoesNotContain( Descendants(screen.Root), static element => element is UiViewport); @@ -88,6 +100,21 @@ public sealed class CharacterManagementLiveDatTests ], rowInfo.States.Keys.Order().ToArray()); + // Campaign LA gate round 2 finding 2: the row template's OWN authored + // justify is Left (character names render left-aligned in retail, not + // centered) — it carries three stateful Type-3 highlight-art children + // (0x10000481-0x10000483, the Normal_rollover/Normal_pressed/Highlight/ + // Highlight_rollover face art) and NO Type-12 caption child, so the row's + // Left justify can only come from ElementInfo.HJustify directly, never a + // lifted text child. DatWidgetFactory.BuildButton must honor it. + Assert.Equal(HJustify.Left, rowInfo.HJustify); + Assert.DoesNotContain(rowInfo.Children, static child => child.Type == 12u); + ImportedLayout? builtRowLayout = LayoutImporter.Import( + dats, template.TemplateLayoutId, template.TemplateElementId, + _ => (0u, 0, 0), null, null); + var builtRow = Assert.IsType(builtRowLayout!.Root); + Assert.Equal(UiButton.LabelAlignment.Left, builtRow.LabelAlign); + uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); Assert.Equal(0x2100003Cu, dialogDid); ImportedLayout message = BuildSelected(dats, dialogDid, 0x24u); @@ -108,6 +135,14 @@ public sealed class CharacterManagementLiveDatTests "ID_CharacterManagement_PleaseWait")); Assert.Equal("Entering World", Resolve(strings, table, "ID_Character_EnteringWorld")); + // Finding 1: MakeConfirmExitDialog@0x004ed250's text + // (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table + // enum 0x10000002 -> 0x23000002). The raw DAT string carries a + // literal two-character "\n" escape (this test's Resolve() helper + // does not normalize it — RetailUiRuntime does, via + // NormalizeRetailNewlines, before handing it to the controller). + Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table, + "ID_CharacterManagement_ConfirmExit")); string confirmation = Assert.IsType(strings.ResolveTemplate( table, "ID_CharacterManagement_DeleteCharacterConfirmation", diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index d1f73ead..678540f6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -99,6 +99,32 @@ public sealed class CharacterManagementUiControllerTests Assert.True(restore.Enabled); } + /// + /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120 + /// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName() + /// onto element 0x1000039B. The controller binds Runtime's borrowed + /// snapshot field to that same element. + /// + [Fact] + public void WorldName_TicksFromSnapshot_IntoTheWorldTextElement() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + var worldText = Assert.IsType(environment.Screen.FindElement( + CharacterManagementUiController.WorldTextElementId)); + + Assert.Equal( + "sawato", + string.Join(" ", worldText.LinesProvider().Select(static line => line.Text))); + + environment.Runtime.SetWorldName("Frostfell"); + controller.Tick(); + + Assert.Equal( + "Frostfell", + string.Join(" ", worldText.LinesProvider().Select(static line => line.Text))); + } + [Fact] public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters() { @@ -346,6 +372,81 @@ public sealed class CharacterManagementUiControllerTests controller.Rows.Select(static row => row.Label!).ToArray()); } + /// + /// Campaign LA gate round 2 finding 1: Exit -> MakeConfirmExitDialog + /// (0x004ed250, retail's confirm-only dialog type 1) -> Cancel/Reject + /// leaves the screen exactly as it was — no exit request reaches + /// Runtime's window-close binding. + /// + [Fact] + public void ExitButton_OpenThenCancel_KeepsScreenActive_NoExitRequested() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton exit = environment.Button( + CharacterManagementUiController.ExitElementId); + + exit.OnClick!(); + + Assert.NotEqual(0u, controller.ConfirmExitDialogContext); + ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation); + Assert.Equal( + "Are you sure you want to leave?", + Message(dialog)); + DialogButton(dialog, RetailConfirmationDialogView.RejectButtonId).OnClick!(); + + Assert.Equal(0, environment.Runtime.RequestExitCalls); + Assert.Equal(0u, controller.ConfirmExitDialogContext); + Assert.False(environment.Dialogs.IsOpen); + Assert.True(controller.Root.Visible); + } + + /// + /// Confirm reaches the SAME graceful-shutdown seam window-close uses — + /// asserted here via the bindings fake, since the controller/Runtime + /// boundary is a plain host Action + /// (), not a + /// generation-gated Runtime command. + /// + [Fact] + public void ExitButton_OpenThenConfirm_ReachesGracefulShutdownSeam() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton exit = environment.Button( + CharacterManagementUiController.ExitElementId); + + exit.OnClick!(); + ImportedLayout dialog = environment.LastDialog(RetailDialogType.Confirmation); + DialogButton(dialog, RetailConfirmationDialogView.AcceptButtonId).OnClick!(); + + Assert.Equal(1, environment.Runtime.RequestExitCalls); + Assert.Equal(0u, controller.ConfirmExitDialogContext); + Assert.False(environment.Dialogs.IsOpen); + } + + /// + /// MakeConfirmExitDialog's own guard (m_confirmExitDialogContext != 0 + /// -> return): a second Exit click while the confirmation is + /// already open does not open a second dialog. + /// + [Fact] + public void ExitButton_SecondClickWhileOpen_IsNoOp() + { + using var environment = new EnvironmentHarness(); + UiButton exit = environment.Button( + CharacterManagementUiController.ExitElementId); + + exit.OnClick!(); + Assert.Equal(1, environment.DialogLayouts.Count( + entry => entry.Type == RetailDialogType.Confirmation)); + + exit.OnClick!(); + + Assert.Equal(1, environment.DialogLayouts.Count( + entry => entry.Type == RetailDialogType.Confirmation)); + } + [Fact] public void AuthoredRowDoubleActivation_EntersTheHighlightedCharacter() { @@ -598,7 +699,8 @@ public sealed class CharacterManagementUiControllerTests name => $"WARNING! {name}\nType DELETE in the box below.", "DELETE", "Please Wait", - "Entering World"); + "Entering World", + "Are you sure you want to leave?"); private static void AssertDetachedAndUnbound(ImportedLayout screen) { @@ -633,6 +735,15 @@ public sealed class CharacterManagementUiControllerTests 0x21000004u, 0x100003A5u)); root.Children.Add(list); + root.Children.Add(new ElementInfo + { + Id = CharacterManagementUiController.WorldTextElementId, + Type = 12u, + X = 21f, + Y = 44f, + Width = 193f, + Height = 110f, + }); root.Children.Add(ButtonInfo( CharacterManagementUiController.CreateElementId)); root.Children.Add(ButtonInfo( @@ -641,6 +752,10 @@ public sealed class CharacterManagementUiControllerTests CharacterManagementUiController.DeleteElementId)); root.Children.Add(ButtonInfo( CharacterManagementUiController.RestoreElementId)); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.CreditsElementId)); + root.Children.Add(ButtonInfo( + CharacterManagementUiController.ExitElementId)); if (includePreview) { root.Children.Add(new ElementInfo @@ -762,7 +877,8 @@ public sealed class CharacterManagementUiControllerTests RequestDelete, ConfirmDelete, Restore, - Cancel); + Cancel, + RequestExit); } public FakeView View { get; } = new(); @@ -773,6 +889,7 @@ public sealed class CharacterManagementUiControllerTests public int ConfirmDeleteCalls { get; private set; } public int CancelCalls { get; private set; } public int RestoreCalls { get; private set; } + public int RequestExitCalls { get; private set; } public RuntimeCommandStatus RestoreStatus { get; set; } = RuntimeCommandStatus.Accepted; public bool ThrowOnRestore { get; set; } @@ -814,6 +931,9 @@ public sealed class CharacterManagementUiControllerTests public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) => Update(snapshot => snapshot with { Lifecycle = lifecycle }); + public void SetWorldName(string worldName) => + Update(snapshot => snapshot with { WorldName = worldName }); + public void SetError(string message) => Update(snapshot => snapshot with { Lifecycle = RuntimeCharacterSelectionLifecycle.AwaitingSelection, @@ -915,6 +1035,8 @@ public sealed class CharacterManagementUiControllerTests return Result(RuntimeCommandStatus.Accepted); } + private void RequestExit() => RequestExitCalls++; + private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId) { RuntimeCharacterSelectionEntry? selected = View.Entries @@ -959,6 +1081,7 @@ public sealed class CharacterManagementUiControllerTests "account", SlotCount: 5, RosterCount: View.Entries.Length, + WorldName: "sawato", highlightedCharacterId, HighlightedDisplayIndex: Array.FindIndex( View.Entries, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index a77d51ff..72880d99 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -305,6 +305,67 @@ public class DatWidgetFactoryTests Assert.IsType(e); } + /// + /// Campaign LA gate round 2 finding 2: the retail character-select row + /// template (LayoutDesc 0x21000004, element 0x100003A5, live-DAT + /// confirmed) authors HJustify=Left DIRECTLY on the row's own + /// UIElement_Button — no separate Type-12 caption child (its label comes + /// from the runtime-bound character name, not an authored string), just + /// three stateful Type-3 highlight-art children. The old guard + /// (!ReferenceEquals(labelInfo, info)) only honored HJustify when + /// the label was LIFTED from a distinct Type-12 child, so a button + /// authoring its own justify with no such child fell through to + /// UiButton's Center default. This reproduces that exact shape. + /// + [Fact] + public void BuildButton_OwnHJustifyLeft_NoTextChild_MultipleStatefulFaces_LabelAlignsLeft() + { + var info = new ElementInfo + { + Type = 1, + Width = 160, + Height = 16, + HJustify = HJustify.Left, + }; + info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" }; + info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" }; + info.States[3u] = new UiStateInfo { Id = 3u, Name = "Highlight" }; + for (int i = 0; i < 3; i++) + { + var face = new ElementInfo { Type = 3, ReadOrder = (uint)i }; + face.StateMedia["Normal_rollover"] = (0x06000000u + (uint)i, 1); + info.Children.Add(face); + } + + var button = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign); + // Direct (non-lifted) case: LabelOffsetX stays at UiButton's own + // default small left padding, not a bogus inner offset. + Assert.Equal(3f, button.LabelOffsetX); + } + + /// + /// A button whose own authored HJustify really is Center (the normal + /// case — CREATE/ENTER/DELETE/RESTORE captions) must stay centered; the + /// fix only widens the Left branch, it must not force every button left. + /// + [Fact] + public void BuildButton_OwnHJustifyCenter_NoTextChild_StaysCentered() + { + var info = new ElementInfo + { + Type = 1, + Width = 160, + Height = 16, + HJustify = HJustify.Center, + }; + + var button = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign); + } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 8baca976..17ac70da 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -729,6 +729,32 @@ public sealed class RetailDialogFactoryTests Height = 32f, }); } + else if (type == RetailDialogType.Confirmation) + { + // Campaign LA gate round 2 finding 1: CharacterManagementUiController's + // exit-confirm dialog is the first BuildDialogLayout consumer that + // exercises RetailDialogType.Confirmation through this synthetic + // builder (other Confirmation coverage in THIS file uses the real + // FixtureLoader.LoadConfirmationDialog() fixture instead). + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationDialogView.AcceptButtonId, + Type = 1u, + X = 80f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationDialogView.RejectButtonId, + Type = 1u, + X = 240f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + } else if (type == RetailDialogType.ConfirmationTextInput) { var field = new ElementInfo diff --git a/tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs new file mode 100644 index 00000000..dd71ebd2 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ServerNameTests.cs @@ -0,0 +1,81 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class ServerNameTests +{ + [Fact] + public void Parse_MirrorsAceSerializer_ExactFields() + { + // Mirrors ACE's GameMessageServerName: opcode, i32 currentConnections, + // i32 maxConnections, String16L serverName. + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(123) + .Write(1000) + .WriteString16L("sawato"); + + ServerName.Parsed parsed = ServerName.Parse(w.ToArray()); + + Assert.Equal(123, parsed.CurrentConnections); + Assert.Equal(1000, parsed.MaxConnections); + Assert.Equal("sawato", parsed.WorldName); + } + + [Fact] + public void Parse_NegativeMaxConnections_PreservesSign() + { + // ACE's default is maxConnections = -1 (unlimited); the field must + // stay signed rather than being read as a huge unsigned value. + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(0) + .Write(-1) + .WriteString16L("Frostfell"); + + ServerName.Parsed parsed = ServerName.Parse(w.ToArray()); + + Assert.Equal(0, parsed.CurrentConnections); + Assert.Equal(-1, parsed.MaxConnections); + Assert.Equal("Frostfell", parsed.WorldName); + } + + [Fact] + public void Parse_EmptyWorldName_RoundTrips() + { + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(0) + .Write(0) + .WriteString16L(string.Empty); + + ServerName.Parsed parsed = ServerName.Parse(w.ToArray()); + + Assert.Equal(string.Empty, parsed.WorldName); + } + + [Fact] + public void Parse_WrongOpcode_Throws() + { + byte[] bytes = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu); + + Assert.Throws(() => ServerName.Parse(bytes)); + } + + [Fact] + public void Parse_TruncatedAfterCurrentConnections_Throws() + { + var w = AceWireWriter.GameMessage(ServerName.Opcode).Write(0); + + Assert.Throws(() => ServerName.Parse(w.ToArray())); + } + + [Fact] + public void Parse_TruncatedBeforeWorldName_Throws() + { + var w = AceWireWriter.GameMessage(ServerName.Opcode) + .Write(0) + .Write(0); + + Assert.Throws(() => ServerName.Parse(w.ToArray())); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs index 71d5f787..74cba578 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -92,6 +92,30 @@ public sealed class WorldSessionCharacterSelectionTests Assert.Equal(1u, current.SecondsGreyedOut); } + [Fact] + public void ServerName_Dispatches_AndPopulatesServerInfo() + { + // Campaign LA gate round 2 finding 3: ACE's SendConnectResponse + // enqueues CharacterList then ServerName in the same batch + // (AuthenticationHandler.cs:257-261) — assert both arrive, in wire + // order, through the same UIQueue dispatch path. + using var session = CreateSession(); + var events = new List(); + session.CharacterListReceived += _ => events.Add("roster"); + session.ServerNameReceived += info => events.Add($"world:{info.WorldName}"); + + byte[] packet = BuildPacket( + BuildRoster(secondsGreyedOut: 0u), + BuildServerName("sawato", currentConnections: 3, maxConnections: 100)); + InvokeProcessDatagram(session, packet); + + Assert.Equal(["roster", "world:sawato"], events); + Assert.NotNull(session.ServerInfo); + Assert.Equal("sawato", session.ServerInfo!.Value.WorldName); + Assert.Equal(3, session.ServerInfo!.Value.CurrentConnections); + Assert.Equal(100, session.ServerInfo!.Value.MaxConnections); + } + [Fact] public void ImmediateEnterWorld_IgnoresNumErrorsSentinelBeforeServerReady() { @@ -183,6 +207,19 @@ public sealed class WorldSessionCharacterSelectionTests return writer.ToArray(); } + private static byte[] BuildServerName( + string worldName, + int currentConnections, + int maxConnections) + { + var writer = new PacketWriter(64); + writer.WriteUInt32(ServerName.Opcode); + writer.WriteUInt32(unchecked((uint)currentConnections)); + writer.WriteUInt32(unchecked((uint)maxConnections)); + writer.WriteString16L(worldName); + return writer.ToArray(); + } + private static byte[] BuildRestoreResponse() { var writer = new PacketWriter(64); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs index c8b79b30..aeb6a4fe 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeCharacterSelectionStateTests.cs @@ -291,6 +291,55 @@ public sealed class RuntimeCharacterSelectionStateTests delta => Assert.Equal(new RuntimeGenerationToken(9), delta.Generation)); } + [Fact] + public void ApplyWorldName_PopulatesSnapshot_IndependentOfRoster() + { + // Campaign LA gate round 2 finding 3: ACE sends ServerName in the + // same batch as CharacterList; ApplyWorldName must not require + // ApplyRoster to have run first (arrival order is not guaranteed). + using var state = new RuntimeCharacterSelectionState(); + state.Begin(new RuntimeGenerationToken(5)); + + Assert.Equal(string.Empty, state.Snapshot.WorldName); + + state.ApplyWorldName("sawato"); + Assert.Equal("sawato", state.Snapshot.WorldName); + + state.ApplyRoster(Roster( + new LiveSessionRosterEntry(0x50000001u, "One", 0u))); + Assert.Equal("sawato", state.Snapshot.WorldName); + Assert.Equal(0x50000001u, state.Snapshot.HighlightedCharacterId); + } + + [Fact] + public void ApplyWorldName_UnchangedValue_DoesNotBumpRevisionOrPublish() + { + using var state = new RuntimeCharacterSelectionState(); + state.Begin(new RuntimeGenerationToken(6)); + state.ApplyWorldName("sawato"); + var deltas = new List(); + using IDisposable subscription = state.View.Subscribe( + new Observer(deltas.Add)); + long revision = state.Snapshot.Revision; + + state.ApplyWorldName("sawato"); + + Assert.Equal(revision, state.Snapshot.Revision); + Assert.Empty(deltas); + } + + [Fact] + public void Reset_ClearsWorldName() + { + using var state = new RuntimeCharacterSelectionState(); + state.Begin(new RuntimeGenerationToken(8)); + state.ApplyWorldName("sawato"); + + state.Reset(new RuntimeGenerationToken(9)); + + Assert.Equal(string.Empty, state.Snapshot.WorldName); + } + private static LiveSessionRosterReport Roster( params LiveSessionRosterEntry[] entries) => new("Canonical", 11, entries); From 2e6d69ddc76bcd38f4bf1f02a4c468e95032d3c9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 11:51:57 +0200 Subject: [PATCH 071/138] docs: file #400 (Credits -> gmCreditsUI, post-LA) and fix the misfiled comment The ef96c554 batch ghosted Credits with a comment claiming it was filed as #397 - that number is the Windows graceful-stop issue and no Credits entry existed. #400 now records the gap properly. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 19 +++++++++++++++++++ .../Layout/CharacterManagementUiController.cs | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 2559c242..f33e559f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,25 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #400 — Character select: Credits button is ghosted; retail opens gmCreditsUI + +**Status:** OPEN (post-LA polish) +**Severity:** LOW +**Filed:** 2026-08-15 (Campaign LA gate round 2, char-select findings batch) +**Component:** `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` + +Retail's character-management screen routes the Credits button +(`0x100003A3`, listbox-base offset 6 in +`gmCharacterManagementUI::ListenToElementMessage @0x004ed5a0`) to +`QueueUIMode(0x10000005)` → `gmCreditsUI` (`Register @0x0047a69e`) — a +scrolling credits screen. acdream ghosts the button (visible, disabled, +no invented action — the same treatment as Create Character). Porting +`gmCreditsUI` is its own small screen (authored layout, scroll behavior, +return-to-select) and is deliberately out of Campaign LA's scope. + +**Acceptance:** Credits opens the ported retail credits screen and +returns to character select; button re-enabled. + ## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate **Status:** DONE (this commit, Campaign LA UI-test slice) — closed via diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index eb2c7c8c..c9c5b4a3 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -134,7 +134,7 @@ internal sealed class CharacterManagementUiController : IDisposable // Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of // scope this round (finding 1 note) — same "future campaign, visibly // ghosted, no invented action" treatment as Create above. Filed as - // issue #397. + // issue #400. _credits.Visible = true; _credits.Enabled = false; _credits.OnClick = null; From 0a7dc7d626e6f3592ea6d67946bee30e83530ec6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:02:33 +0200 Subject: [PATCH 072/138] =?UTF-8?q?fix(runtime,ui):=20Campaign=20LA=20gate?= =?UTF-8?q?=20round=202=20=E2=80=94=20world=20name=20reads=20durably;=20di?= =?UTF-8?q?alogs=20center=20on=20the=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live-integration gaps the ef96c554 unit tests could not see: 1. World box stayed empty against ACE: ServerName (0xF7E1) arrives in the SAME connect batch as CharacterList, so ServerNameReceived fires during the handshake pump BEFORE the controller binding subscribes - the event-only wiring proved the state and controller but never the live ordering. StartCore now reads the durable WorldSession.ServerInfo after connect exactly like the roster (ILiveSessionOperations.GetServerInfo, default interface method so no fake breaks); the event remains for post-connect updates. Pinned by a Start-level test. 2. The exit confirmation rendered far right of the screen: all three retail dialog views centered against the raw window size while the active screen lays out in the fixed 800x600 canvas - center-of-1920 is canvas-760, which the stretch pushes off-center. Views now center against UiRoot.EffectiveCanvasSize (canvas while a pre-world screen is active, window otherwise). Pinned by growing the window over the fixed canvas in the exit-dialog test and asserting the scrim spans the canvas with the popup centered at 400. Runtime 1666, App 5100+6 skips, green. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/RetailConfirmationDialogView.cs | 9 +++-- .../RetailConfirmationTextInputDialogView.cs | 7 ++-- .../UI/Layout/RetailMessageDialogView.cs | 7 ++-- src/AcDream.App/UI/UiRoot.cs | 13 +++++++ .../Session/LiveSessionController.cs | 18 ++++++++++ .../CharacterManagementUiControllerTests.cs | 15 ++++++++ .../Session/LiveSessionControllerTests.cs | 34 +++++++++++++++++++ 7 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs index 71cc4673..d21dbddc 100644 --- a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs @@ -129,10 +129,15 @@ internal sealed class RetailConfirmationDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the space the tree lays out in — the fixed authored + // canvas while a pre-world screen is active (gate round 2: centering + // against the raw window width put the exit dialog far right of the + // stretched 800x600 canvas center). + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs index e21d330b..1b1467c8 100644 --- a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs @@ -150,10 +150,13 @@ internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — see RetailConfirmationDialogView.SizeAndCenter. + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs index a9246ac5..9ec4d987 100644 --- a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs @@ -95,10 +95,13 @@ internal sealed class RetailMessageDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — see RetailConfirmationDialogView.SizeAndCenter. + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index fe3246b1..7ca428ac 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -44,6 +44,19 @@ public sealed class UiRoot : UiElement /// public Vector2? FixedCanvasSize { get; set; } + /// + /// The coordinate space the retained tree currently lays out in: the fixed + /// authored canvas while one is active, else the window itself. Anything + /// that positions against "the screen" (dialog centering, full-screen + /// scrims) must use THIS — the gate-round-2 exit dialog centered against + /// the 1920px window while the tree lived in the 800px canvas, landing far + /// right of the visible screen center. + /// + public Vector2 EffectiveCanvasSize => + FixedCanvasSize is { X: > 0f, Y: > 0f } canvas + ? canvas + : new Vector2(Width, Height); + /// Window→canvas stretch factor; One when no fixed canvas is set. public Vector2 CanvasScale => FixedCanvasSize is { X: > 0f, Y: > 0f } canvas && Width > 0f && Height > 0f diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 3cdc3413..5973e22c 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -172,6 +172,16 @@ public interface ILiveSessionOperations WorldSession CreateSession(IPEndPoint endpoint); void Connect(WorldSession session, string user, string password); CharacterList.Parsed? GetCharacters(WorldSession session); + /// + /// Campaign LA gate round 2 (world-name live fix): ACE sends ServerName + /// (0xF7E1) in the SAME connect-response batch as CharacterList, so the + /// ServerNameReceived event fires during the handshake pump — + /// BEFORE the controller's binding subscribes. The durable + /// field is therefore read + /// synchronously after Connect, exactly like + /// reads the durable roster; the event remains for post-connect updates. + /// + ServerName.Parsed? GetServerInfo(WorldSession session) => session.ServerInfo; void StartCharacterSelectionReceive(WorldSession session) => session.StartCharacterSelectionReceive(); void EnterWorld(WorldSession session, int activeCharacterIndex); @@ -753,6 +763,14 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } + // World name arrives in the SAME connect batch as CharacterList, + // during the handshake pump — before the binding's + // ServerNameReceived subscription exists — so the durable field is + // read here exactly like the roster above (gate-round-2 live fix: + // the event-only wiring left the World box empty against ACE). + if (_operations.GetServerInfo(session) is { } serverInfo) + CharacterSelectionState.ApplyWorldName(serverInfo.WorldName); + // Campaign LA slice LA2: the probe short-circuit lands here — // only after a real CharacterList was returned and its roster was // reported, before TrySelectCharacter ever runs. A missing diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index 678540f6..b75345e0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -386,6 +386,13 @@ public sealed class CharacterManagementUiControllerTests UiButton exit = environment.Button( CharacterManagementUiController.ExitElementId); + // Gate round 2 follow-up: the window is BIGGER than the fixed canvas + // (the live shape — 1920×1080 window over the 800×600 authored + // screen). The exit dialog centered against the raw window width and + // landed far right of the visible canvas center. + environment.Host.Width = 1920f; + environment.Host.Height = 1080f; + exit.OnClick!(); Assert.NotEqual(0u, controller.ConfirmExitDialogContext); @@ -393,6 +400,14 @@ public sealed class CharacterManagementUiControllerTests Assert.Equal( "Are you sure you want to leave?", Message(dialog)); + // The dialog's scrim + popup must live in CANVAS space: scrim spans + // exactly the canvas, popup centers within it. + Assert.Equal(800f, dialog.Root.Width); + Assert.Equal(600f, dialog.Root.Height); + UiElement popup = Assert.Single( + dialog.Root.Children, + static child => child.Visible && child.Width > 0f); + Assert.InRange(popup.Left + popup.Width * 0.5f, 399f, 401f); DialogButton(dialog, RetailConfirmationDialogView.RejectButtonId).OnClick!(); Assert.Equal(0, environment.Runtime.RequestExitCalls); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 02d8bee4..b2d752a6 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -113,6 +113,13 @@ public sealed class LiveSessionControllerTests return Characters; } + /// Gate-round-2 world-name live fix: mirrors the durable + /// post-connect read the production path performs (the wire event + /// fires during the handshake, before any subscriber exists). + public ServerName.Parsed? ServerInfo { get; set; } + + public ServerName.Parsed? GetServerInfo(WorldSession session) => ServerInfo; + public void StartCharacterSelectionReceive(WorldSession session) => calls.Add("start-character-selection-receive"); @@ -349,6 +356,33 @@ public sealed class LiveSessionControllerTests Assert.True(host.CommandBuses[0].Active); } + /// + /// Gate-round-2 live fix: ACE sends ServerName (0xF7E1) in the SAME + /// connect batch as CharacterList, so the event fires during the + /// handshake pump before the binding subscribes — the World box stayed + /// empty against a live server while the event-only unit tests passed. + /// Start must read the durable ServerInfo after connect, exactly like + /// the roster. + /// + [Fact] + public void Start_AppliesWorldNameFromDurableServerInfoAfterConnect() + { + var calls = new List(); + var operations = new TestOperations(calls) + { + ServerInfo = new ServerName.Parsed(1, 128, "sawato"), + }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + + Assert.Equal(LiveSessionStartStatus.Connected, result.Status); + Assert.Equal( + "sawato", + controller.CharacterSelectionState.View.Snapshot.WorldName); + } + [Fact] public void Start_ReportsRosterFromCharacterListBeforeSelection() { From 36c14902a8fedd44de3aaabd438984c417a1f27e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:07:04 +0200 Subject: [PATCH 073/138] =?UTF-8?q?docs:=20Campaign=20LA=20gate=20round=20?= =?UTF-8?q?2=20=E2=80=94=20char-select=20matrix=20USER-PASSED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index bfdb815f..c4040ae6 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -756,7 +756,22 @@ optional Path.IsPathFullyQualified hardening on the crash reporter's --data-dir fallback. Launcher 67/67, Launcher.Core 317/317. The §A–I connected script remains the open user gate. -## Gate round 2 — 2026-08-15 (first live launcher→client flow) +## Gate round 2 — 2026-08-15 (first live launcher→client flow) — char-select matrix USER-PASSED + +**USER-PASSED 2026-08-15 (end of round):** the character-select visual/ +interaction matrix — stretched-canvas look with bilinear filtering, +aligned widgets, left-justified roster, World box reading the live server +name ("sawato"), and the centered exit confirmation — all accepted on the +live launcher→client flow. Round-2 commits after the round-1 batch: +`6e1c0967` (session-config launches force the retail UI), `9ce72925` +(PFID_CUSTOM_RAW_JPEG decode + resolution guards), `73041d70` +(whole-canvas AD-98 scale + inverse input), `308f40a3` (linear-twin +bilinear stretch), `ef96c554` (exit confirmation + authored justify + +world name, AD-99), `2e6d69dd` (#400), `0a7dc7d6` (durable world-name +read + canvas-centered dialogs). Remaining before shipment: the formal +§A–I script rows (probe ×2, headless+plugins+login commands, delete/ +restore, A→B update swap, row I Linux), and the final-HEAD preflight +re-run. Two real defects, both root-caused and fixed: From 0baebce262d593c3b56ebafb20d2689cf2907e02 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:28:06 +0200 Subject: [PATCH 074/138] fix(ui,runtime): Campaign LA gate-round-2 batch-review fixes F1,F3-F8; file #401 F1 (MUST-FIX): RetailWaitDialogView was the ONE dialog view the 0a7dc7d6 EffectiveCanvasSize sweep missed - the Entering World wait dialog (fires on ENTER, the char screen primary action) still centered against the raw window and landed off the visible canvas. Same three-line fix as its three siblings; the enter-wait test now grows the window over the fixed canvas and asserts canvas-space centering. F3/F4: two stale assertions about the DELETED first AD-98 substitution (the register section-2 header line and the live-DAT oracle test doc) now describe the completed FixedCanvasSize mechanism - the C4-closeout failure mode, caught before it cost anything. F5: RetailDialogData.Confirmation sets ElementAttribute40 itself (retail MakeConfirmExitDialog writes 0x8E=1, 0xAC=1, 0xC5); the manual set in GameplayConfirmationController is gone. F6: MapWindowToCanvas truncates instead of rounding - rounding mapped the window far edge one past the canvas last valid coordinate, a 1px dead hit-test band; test updated to truncation semantics + far-edge case. F7: AD-98 records that the no-letterbox aspect claim has no decomp citation and is confirmed by the user live gate pass 2026-08-15. F8: the durable world-name read in StartCore is IsCurrent-gated like every neighbouring step. F2 filed as #401 (invert RetailUi to opt-out - product-default decision, not a gate fix). App 5100+6 skips, Runtime 1666, green. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 26 +++++++++++++++++++ .../retail-divergence-register.md | 4 +-- .../UI/GameplayConfirmationController.cs | 5 ++-- src/AcDream.App/UI/Layout/RetailDialogData.cs | 5 ++++ .../UI/Layout/RetailWaitDialogView.cs | 9 +++++-- src/AcDream.App/UI/UiRoot.cs | 6 ++++- .../Session/LiveSessionController.cs | 6 ++++- .../Layout/CharacterManagementLiveDatTests.cs | 17 ++++++------ .../CharacterManagementUiControllerTests.cs | 17 ++++++++++-- .../UI/UiRootFixedCanvasTests.cs | 11 +++++++- 10 files changed, 87 insertions(+), 19 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index f33e559f..f209a429 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,32 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #401 — RetailUi should default ON (opt-out), not per-path forced + +**Status:** OPEN (product-default decision) +**Severity:** MEDIUM (recurrence risk) +**Filed:** 2026-08-15 (Campaign LA gate-round-2 batch review, F2) +**Component:** `src/AcDream.App/RuntimeOptions.cs` + +`RetailUi` still parses opt-IN from `ACDREAM_RETAIL_UI` (default false), and +`6e1c0967` forces it true on exactly one call site (the session-config +launch path). Any other product entry point — including CLAUDE.md's +documented plain `dotnet run` dev launch — still boots world rendering with +zero interface, the same trap one caller later. The ImGui frontend is gone +(Campaign V), so `RetailUi == false` means "no UI at all"; the review +confirmed nothing legitimately needs that in a product or test path. + +**Fix direction:** invert the flag — retail UI on by default, +`ACDREAM_RETAIL_UI=0` as the dev opt-OUT — and delete the per-path forcing +in `RuntimeOptions.FromSessionConfig`. Sweep launch scripts/docs +(CLAUDE.md's launch command, test-script env listings) for stale +`ACDREAM_RETAIL_UI=1` mentions in the same change. Also pin the currently +untested "explicit `ACDREAM_RETAIL_UI=0` alongside a session config is +ignored" behavior — or make the inversion moot it. + +**Acceptance:** every launch path shows the retail UI unless explicitly +opted out; the forcing is gone; docs updated. + ## #400 — Character select: Credits button is ghosted; retail opens gmCreditsUI **Status:** OPEN (post-LA polish) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 01fc2d58..8fb46bb4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 75 active rows (AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2 — the char-select root's own background stretches instead of tiling by resizing the mounted root element to the live viewport and marking its background quad UV 0..1, substituting for retail's fixed-800x600-canvas-stretched-at-presentation mechanism which acdream's live-resolution render pipeline has no analogue for; AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 75 active rows (AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -189,7 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | -| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored` | +| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | 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-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) | diff --git a/src/AcDream.App/UI/GameplayConfirmationController.cs b/src/AcDream.App/UI/GameplayConfirmationController.cs index 75758734..4e36cfa7 100644 --- a/src/AcDream.App/UI/GameplayConfirmationController.cs +++ b/src/AcDream.App/UI/GameplayConfirmationController.cs @@ -58,8 +58,9 @@ public sealed class GameplayConfirmationController : IDisposable ? request.Message + " Continue?" : _composeMessage?.Invoke(request.Type, request.Message) ?? request.Message; - var data = RetailDialogData.Confirmation(message) - .Set(RetailDialogProperty.ElementAttribute40, true); + // ElementAttribute40 now comes from RetailDialogData.Confirmation + // itself (batch review F5 — retail's confirmation builders all set it). + var data = RetailDialogData.Confirmation(message); _dialogContext = _dialogs.MakeDialog(data); return _dialogContext != 0u; } diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs index d8b61558..3dd769e5 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -108,11 +108,16 @@ public sealed class RetailDialogData return clone; } + /// Type-1 confirmation data. Sets element attribute 0x40 — retail's + /// own confirmation builders do (e.g. MakeConfirmExitDialog @0x004ed250 + /// writes 0x8E=1, 0xAC=1, 0xC5=message), same as the Wait/TextInput factories + /// below (gate-round-2 batch review F5). public static RetailDialogData Confirmation(string message) { ArgumentNullException.ThrowIfNull(message); return new RetailDialogData() .Set(RetailDialogProperty.Type, RetailDialogType.Confirmation) + .Set(RetailDialogProperty.ElementAttribute40, true) .Set(RetailDialogProperty.Message, message); } diff --git a/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs b/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs index 39355361..b5175509 100644 --- a/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs @@ -95,10 +95,15 @@ internal sealed class RetailWaitDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — gate-round-2 batch review F1: this was the ONE + // dialog view the 0a7dc7d6 sweep missed, and it fires on ENTER (the + // char screen's primary action), centering off the visible canvas. + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 7ca428ac..087053ac 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -65,10 +65,14 @@ public sealed class UiRoot : UiElement private (int x, int y) MapWindowToCanvas(int x, int y) { + // Truncate, not round (batch review F6): rounding maps the window's + // last column/row one past the canvas's last valid coordinate + // (1919/2.4 → 800, past 799), creating a 1px dead band at the far + // right/bottom edge. Truncation maps 0..1919 onto 0..799 exactly. Vector2 scale = CanvasScale; return scale == Vector2.One ? (x, y) - : ((int)MathF.Round(x / scale.X), (int)MathF.Round(y / scale.Y)); + : ((int)(x / scale.X), (int)(y / scale.Y)); } // ── Device-level state ─────────────────────────────────────────────── diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 5973e22c..c96c3c5a 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -768,7 +768,11 @@ public sealed class LiveSessionController // ServerNameReceived subscription exists — so the durable field is // read here exactly like the roster above (gate-round-2 live fix: // the event-only wiring left the World box empty against ACE). - if (_operations.GetServerInfo(session) is { } serverInfo) + // IsCurrent-gated like every neighbouring step (batch review F8): + // a generation flip here must not write the old server's name + // into the new generation's state. + if (IsCurrent(scope, generation) + && _operations.GetServerInfo(session) is { } serverInfo) CharacterSelectionState.ApplyWorldName(serverInfo.WorldName); // Campaign LA slice LA2: the probe short-circuit lands here — diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 500ca6ba..6c01c054 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -261,14 +261,15 @@ public sealed class CharacterManagementLiveDatTests /// UIElement::UpdateForParentSizeChange, acclient 0x00462640). Retail's /// own edge-anchor resize mechanism therefore NEVER touches this element's size; /// it stays a fixed 800x600 rect in retail's own widget tree. This is the pivot - /// fact behind : - /// since the dat itself asks for no resize, whatever makes the char-select scene - /// fill an arbitrary window resolution in retail (background AND buttons AND - /// listbox together) cannot be a per-element anchor/draw-mode difference — it has - /// to be an out-of-band presentation-time scale of the whole fixed-size frame. - /// acdream instead resizes the MOUNTED root itself (CharacterManagementUiController's - /// constructor) to reach the same visual fill, which is why the background needs - /// its own explicit stretch flag rather than an authored draw-mode bit. + /// fact behind the completed AD-98 substitution + /// (): since the dat itself + /// asks for no resize, whatever makes the char-select scene fill an arbitrary + /// window resolution in retail (background AND buttons AND listbox together) + /// cannot be a per-element anchor/draw-mode difference — it has to be an + /// out-of-band presentation-time scale of the whole fixed-size frame. acdream + /// therefore keeps the mounted root at its authored 800x600 extent and + /// stretches the ENTIRE canvas — widgets, glyphs, art — as one unit at the + /// renderer's quad chokepoint, with inverse mouse mapping. /// [InstalledDatFact] public void RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf() diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index b75345e0..7e884c59 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -511,6 +511,13 @@ public sealed class CharacterManagementUiControllerTests Assert.Equal(0u, controller.OperationWaitContext); Assert.False(environment.Dialogs.IsOpen); + // Gate-round-2 batch review F1: the wait dialog must center in CANVAS + // space like every other dialog view — it fires on ENTER, the + // screen's primary action, and was the one view the EffectiveCanvasSize + // sweep missed. The window is bigger than the fixed canvas here. + environment.Host.Width = 1920f; + environment.Host.Height = 1080f; + controller.Rows[0].OnClick!(); environment.Button(CharacterManagementUiController.EnterElementId).OnClick!(); Assert.Equal(1, environment.Runtime.EnterCalls); @@ -518,8 +525,14 @@ public sealed class CharacterManagementUiControllerTests RuntimeCharacterSelectionLifecycle.EnteringWorld, environment.Runtime.View.Snapshot.Lifecycle); Assert.NotEqual(0u, controller.EnterWaitContext); - Assert.Equal("Entering World", Message( - environment.LastDialog(RetailDialogType.Wait))); + ImportedLayout enterWait = environment.LastDialog(RetailDialogType.Wait); + Assert.Equal("Entering World", Message(enterWait)); + Assert.Equal(800f, enterWait.Root.Width); + Assert.Equal(600f, enterWait.Root.Height); + UiElement enterWaitPopup = Assert.Single( + enterWait.Root.Children, + static child => child.Visible && child.Width > 0f); + Assert.InRange(enterWaitPopup.Left + enterWaitPopup.Width * 0.5f, 399f, 401f); environment.Runtime.SetError("That character is unavailable."); controller.Tick(); diff --git a/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs b/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs index 79a6a6a9..d520fb7b 100644 --- a/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs @@ -86,11 +86,20 @@ public class UiRootFixedCanvasTests }; root.AddChild(button); + // Truncation, not rounding (batch review F6): 860/2.4 = 358.33 → 358, + // 750/1.8 = 416.67 → 416. Rounding mapped the window's far edge one + // past the canvas's last valid coordinate (1919 → 800), losing a 1px + // hit-test band at the right/bottom. root.OnMouseDown(UiMouseButton.Left, 860, 750); root.OnMouseUp(UiMouseButton.Left, 860, 750); Assert.Equal(1, clicks); Assert.Equal(358, root.MouseX); - Assert.Equal(417, root.MouseY); + Assert.Equal(416, root.MouseY); + + // The far window edge maps INSIDE the canvas. + root.OnMouseMove(1919, 1079); + Assert.Equal(799, root.MouseX); + Assert.Equal(599, root.MouseY); root.OnMouseDown(UiMouseButton.Left, 300, 400); root.OnMouseUp(UiMouseButton.Left, 300, 400); From ee80138fba7a665dd88f143d4beefe00f483a557 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:28:33 +0200 Subject: [PATCH 075/138] =?UTF-8?q?docs:=20Campaign=20CC=20plan=20?= =?UTF-8?q?=E2=80=94=20retail=20character=20creation,=20slices=20CC1-CC7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recon-grounded (three parallel sweeps 2026-08-15): full retail flow (gmCharGenMainUI mode 0x1000000b, six ECGProgress pages with every widget id), byte-exact 0xF656 layout incl. the trailing checksum ACE never reads, 0xF643 create semantics (local roster append + retail logs straight in; no fresh CharacterList), the chargen DAT table shape, the preview rig (gmCG3DView over CreatureMode - Appearance/Summary pages only), and every acdream seam to reuse (generic layout resolver, fixed canvas, offscreen viewport pipeline, RuntimeCharacterSelectionState as the J-owner template, the 0xF643 correlation landmine). Slice order CC1 data / CC2 wire (parallel) then CC3 Runtime owner, CC4/CC5 form pages, CC6a/b appearance+preview staged, CC7 end-to-end + user gate. Co-Authored-By: Claude Fable 5 --- .../2026-08-15-character-creation-campaign.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/plans/2026-08-15-character-creation-campaign.md diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md new file mode 100644 index 00000000..2cfe0504 --- /dev/null +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -0,0 +1,210 @@ +# Campaign CC — retail character creation + +**Status:** ACTIVE (started 2026-08-15) +**Goal (user-set):** the full retail creation flow against local ACE — Create +button through a new character entering the world, 3D preview live, rejections +showing retail's dialogs — then stop for the user gate. +**Branch:** `claude/acdream-launcher-credentials-4d2f7c` +**Process:** Campaign LA's, binding (Sonnet implements, Opus dual-lens reviews +per slice, retail decomp is the oracle, register rows with deviations, +build+test green per slice, commits tagged `Campaign CC`). + +This plan embeds the 2026-08-15 recon facts (three parallel sweeps: retail +gmCG UI, chargen data+wire, acdream seams) so slices and future sessions need +no transcript access. `references/ACE` and `references/holtburger` are NOT in +this worktree (gitignored) — read them from the main checkout at +`C:\Users\erikn\source\repos\acdream\references\`. + +## Retail ground truth (recon summary — cite these in code) + +**Flow.** Create button (`0x100003A0`) → `QueueUIMode(0x1000000b)` → +`gmCharGenMainUI` (acclient.h:56232): ONE root layout, enum `0x10000039` via +GetDIDByEnum table 5 (our generic `RetailDataIdResolver` handles this), pages +as children. `ECGProgress`: Heritage=1 → Profession=2 → Skills=3 → +Appearance=4 → Town=5 → Summary=6. Nav dispatch +`gmCharGenMainUI::ListenToElementMessage@237025`: Back `0x100003c6` (at +Heritage → DoExit), Next `0x100003c7`, Finish `0x100003c8` (Summary only), +Help `0x100003c9`, Exit `0x100003ca` (→ `ID_CharGen_ExitWarning` confirm), +Random `0x100003cb` (on Summary → randomize warning first). Tab buttons +`0x100003ef..f4` jump pages freely (not validation-gated). Page roots: +Heritage `0x100003d1`, Profession `0x100003d2`, Skills `0x100003d3`, +Appearance `0x100003d4`, Town `0x100003d5`, Summary `0x100003d6`; progress +bar `0x100003ce`, master page `0x100003d0`. Per-page child ids are in the +recon-cited ctors: Heritage `InitializePage@143731` (13 race buttons + text +`0x100003c4`), Profession `@143010` (6 attribute sliders `0x100003e6..eb`, +avail/health/stam/mana `0x100003e2..e5`, template buttons resolved in +`UpdateProfession@142180`: Custom `0x100003d9`, Bowhunter/Swashbuckler/ +Lifecaster/Warmage/Wayfarer/Soldier `0x100003da..df`), Skills `@141911` +(listbox `0x100003f7`, credits `0x100002f3`, info `0x100003fb/fc`), +Appearance `@140032` (gender `0x100003a7/a8`, spins hair/eyes/nose/mouth/skin +`0x100003af..b3`, headgear/shirt/trousers/footwear `0x100003b5..b8`, zoom +`0x10000325/26`, rotate `0x10000323/24`, color wheel family +`0x1000030e..0x10000321`, viewport `0x100003bb`), Town `@137120` (Sanamar +`0x1000040b`, Holtburg `0x1000040d`, Yaraq `0x1000040e`, Shoushi +`0x1000040f`), Summary `@136566` (list `0x10000400`, name text `0x10000402` +with NameInputFilter, viewport `0x10000406`). + +**CharGenState** (acclient.h:40074): the model our Runtime owner mirrors — +heritage/gender, appearance strips+styles+colors+shades (f64 shades), +template + 6 attributes + credit budgets + per-attribute locks, 55-slot +skill advancement array + skill credits, name[33], startArea, setupID, +verificationState. Writers per page in the recon (SetHeritageGroup recomputes +budgets + ApplyTemplate + RandomizeStartArea; SetGender reapplies clothing +and UpdateTrueFacePal). + +**Finish** (`DoFinish@236864`): trim+set name → empty name → +`ID_CharGen_NoNameWarning`, abort; `remainingAtrbCredits > 0` → credit +warning, abort (retail FORCES full spend — ACE does not; we port the client +gate); verification state must be UNDEF (no double submit) → set PENDING → +`Proto_UI::SendCharGenResult@0x00546A70`. + +**Wire 0xF656** (`ACCharGenResult::CG_Pack@0x005C7200`, byte-identical to +ACE's `CharacterCreateInfo.Unpack`): account String16L FIRST (outside the +body), then u32 constant 1, u32 heritage, u32 gender, u32×3 eyes/nose/mouth +strips, u32×2 hairColor/eyeColor, u32 hairStyle, u32×2 headgearStyle/Color, +u32×2 shirt, u32×2 trousers, u32×2 footwear, f64×6 skin/hair/headgear/shirt/ +trousers/footwear shades, u32 templateNum, u32×6 attributes +(str/end/coord/quick/focus/self), u32 slot, u32 classID, u32 numSkills + +numSkills×u32 advancement classes (MUST be exactly 55 — ACE TERMINATES the +session on mismatch), String16L name, u32 startArea, u32 isAdmin, u32 +isEnvoy(=ACE IsSentinel), u32 trailing checksum = sum of +heritage+gender+strips(3)+hairColor+eyeColor+hairStyle+headgearStyle+ +shirtStyle+trousersStyle+footwearStyle+template+6 attributes (ACE never +reads it; we send it for byte fidelity). holtburger cross-check: +`character/types.rs:236` (stops before the checksum). + +**Response 0xF643** (shared opcode with restore — LA7a's conditional parse is +reusable): codes Undef=0 Ok=1 Pending=2 NameInUse=3 NameBanned=4 Corrupt=5 +DatabaseDown=6 AdminPrivilegeDenied=7. On Ok the payload is a +CharacterIdentity (guid, String16L name, u32 secondsGreyedOut) and NOBODY +sends a fresh CharacterList — retail appends the identity to its local +roster (`Handle_CharGenVerificationResponse@0x0055E8B0` case 1 → +`CharacterSet::AddIdentity`) and `gmCharGenMainUI::Update@236161` then +watches the set and calls `CPlayerSystem::LogOnCharacter` DIRECTLY when the +new name appears (logs straight in; only falls back to char management if it +never appears). Error dialogs: NameInUse→`ID_Character_Err_NameReserved`, +NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→ +`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→ +`ID_Character_Err_NameAdminDenied`, Pending/Undef→silent state reset (ACE +sends Pending for a disabled-Olthoi rejection — retail swallows it; port +as-is, register-note the quirk). + +**Chargen DAT table** `0x0E000002`: readable TODAY via the +Chorizite.DatReaderWriter package (`dats.Get`) — zero in-tree +readers exist. ACE loaders (`ACE.DatLoader.FileTypes.CharGen` + +`HeritageGroupCG/SexCG/TemplateCG`) and retail serializers +(`ACCharGenData::Serialize@0x005C36D0`, `HeritageGroup_CG@0x005C2100`, +`Sex_CG@0x005C1600`, `Template_CG@0x005C0450`) define the shape: per +heritage → name/icon/setup/EnvironmentSetup/attribute+skill credits/start +areas/skills(costs)/templates(attrs+skills)/genders; per sex → scale, setup, +base palette, skin palset, base ObjDesc, and the option LISTS (hair styles/ +colors, eye colors, eye/nose/mouth strips, headgear/shirt/pants/footwear, +clothing colors). + +**3D preview** (`gmCG3DView`, Appearance `0x100003bb` + Summary `0x10000406` +ONLY — the other four pages have no viewport): preview body +`CPhysicsObj::makeObject(setupId)` (fallback HUMAN_SETUP_ID), rebuild on +change via ObjDesc (`ClothingTable::BuildObjDesc` per clothing slot + strips ++ PalSet skin/hair/eye subpalettes) applied with +`DoObjDescChangesFromDefault@242308`, one DISTANT_LIGHT (intensity 2.0), +idle animation loop at 30fps (`set_sequence_animation`), rest-pose freeze on +zoom-in, BUTTON-toggled continuous rotation (`DoRotation@137337`, 3.0 +s/revolution, per-frame global-message-3 tick), zoom tween between +per-heritage camera positions (`Update@138974` hard-codes Olthoi vs +human-form camera offsets). + +## acdream seams (build on these, do not reinvent) + +- Layout mount: `RetailDataIdResolver.Resolve(dats, 0x10000039, 5)` + + `LayoutImporter` — fully generic. `DatWidgetFactory` already maps dat type + 0xD → `UiViewport`. The char-management controller REFUSES viewports by + local policy (:212) — chargen gets its OWN controller; clone + `CharacterManagementUiMountCoordinator` + the bindings-record pattern. +- Fixed canvas: chargen is the same 800×600 flow screen — mount at authored + extent, `UiRoot.FixedCanvasSize` on activate (AD-98), dialogs center on + `EffectiveCanvasSize`. Live-DAT probe tests sweep ALL media ids + (`CharacterManagementLiveDatTests` pattern) and pin authored + justify/anchors. +- Preview pipeline: `PrivateEntityViewportRenderer` (offscreen target → + texture table → `UiViewport` sprite) is proven by paperdoll + appraisal; + cameras there are FIXED — chargen needs a heading-capable camera. NOTE: + `GlGpuDevice.RegisterExternalColorTexture` is a DELETED API that survives + only in stale doc comments — do not cite it. Appearance building: + `DollEntityBuilder.Build` is index-agnostic and pure (setup + resolved + palette/part ids), but the only existing factory reads a LIVE entity — + chargen needs a new index→dat→ObjDesc factory (SexCG.BaseObjDesc + strip + overlays + PalSet.GetPaletteID hues). Pose: paperdoll holds a static final + frame; retail chargen plays a live idle loop — see slice CC6 for the + staged approach. +- Runtime owner: mirror `RuntimeCharacterSelectionState` exactly (lifecycle/ + snapshot/delta records, borrow-only view, generation-gated commands, one + mutable owner, no App types). Command family lands beside + `IGameRuntimeCommands.CharacterSelection`. Enter-after-create hooks the + existing `LiveSessionController.BeginEnter/CompleteEnter`. +- Wire plumbing: `WorldSession`'s dispatch chain routes EVERY 0xF643 through + `CharacterRestore.Parse` today with no request correlation — the KNOWN + LANDMINE. Creation requires an awaiting-request latch (create vs restore) + BEFORE its response arm lands. Outbound mirrors + `SendRestoreCharacter@2223`. Status writer: add `characterCreated` / + `creationFailed` events (update the pinned §LA1 contract text + the + Launcher.Core tailer + tests in lockstep). + +## Slices + +| Slice | Deliverable | Depends | +|---|---|---| +| CC1 | Chargen data layer: `CharGen` table reader → typed options model (heritages/sexes/appearance lists/templates/skills+costs/budgets/towns), Content/Core, live-DAT probes | — | +| CC2 | Wire: `CharacterCreate` 0xF656 builder (byte-exact incl. checksum), shared verification-response type (refactor from `CharacterRestore`), WorldSession request-correlation for 0xF643, send seam, status events + contract/tailer update | — | +| CC3 | `RuntimeCharacterCreationState`: full CharGenState mirror, per-page commands, retail client gates (full-spend, name, 55-slot invariant, client-side slot cap), verification latch, Ok → roster append + retail log-straight-in | CC1, CC2 | +| CC4 | Screen shell + form pages (App): mount (enum 0x10000039), master nav/tabs/progress, dialogs, Heritage + Profession + Skills + Town pages | CC1, CC3 | +| CC5 | Summary page: name input (NameInputFilter, `ID_CharGen_NameTooLong`), summary listbox, static summary viewport, Finish gates + full response/dialog handling | CC3, CC4 | +| CC6 | Appearance page + preview: index→ObjDesc factory, chargen preview renderer (offscreen, heading camera, rotate/zoom buttons), spin controls + color wheels; **staged:** CC6a static-pose preview (paperdoll-style held frame, register row for the missing idle loop), CC6b idle animation + zoom rest-freeze (retire the row) | CC1, CC4 | +| CC7 | End-to-end: Create button un-ghosts, full flow vs ACE shapes in tests, launcher payload cycle, connected checklist doc | all | + +Parallelism: CC1 ∥ CC2 (disjoint: Content/Core vs Core.Net; separate +worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7. + +## Risks / open items (from recon Unknowns) + +1. 0xF643 create/restore correlation (CC2's first job; the restore doc + comment already warns). +2. 55-slot skill array: ACE terminates the session on mismatch — CC2/CC3 + must make it structurally impossible to send anything else. +3. Slot cap is client-enforced only (ACE never checks on create) — honor + `slotCount` like retail's UI did. +4. Color-wheel/gradient widgets (`tagColorWheel`, GradCircle `0x1000030e`, + shade scroll) may need new widget types in `DatWidgetFactory` — CC6 + scouts the authored layout first. +5. Retail unknowns to resolve during slices, never guess: the chargen + please-wait dialog context (decompiler-mislabeled field), the + AppearancePage gender-flip-on-init oddity (@140355 — verify live before + porting), `Method_CG` enums are empty in the header, ZoomIn tween + duration constant is decompiler-garbled (measure against retail if it + matters). +6. Viewport inside the fixed canvas: the offscreen target's pixel size vs + the canvas-scaled on-screen rect (render at scaled size for crispness or + authored size for fidelity) — decide in CC6a with the user gate as + arbiter. +7. `references/*` absent in worktrees (except WorldBuilder, uninitialized + submodule) — agents read ACE/holtburger from the MAIN checkout path. + +## Review protocol + +Per slice: implement → Opus dual-lens (architectural + retail fidelity — this +campaign is retail-heavy everywhere) → fixes → narrow re-review → DONE in +ledger. CC2's review adds wire-byte scrutiny (the LA7a precedent: the +reviewer decodes the binary); CC6's adds the visual-fidelity lens ahead of +the user gate. + +## Ledger + +| Slice | Status | Commits | Review | Notes | +|---|---|---|---|---| +| CC1 | — | | | | +| CC2 | — | | | | +| CC3 | — | | | | +| CC4 | — | | | | +| CC5 | — | | | | +| CC6a | — | | | | +| CC6b | — | | | | +| CC7 | — | | | | From c3a8c231b8a07c5ad9ef2f6050c52b45216c0211 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:30:59 +0200 Subject: [PATCH 076/138] =?UTF-8?q?docs:=20Campaign=20LA=20gate=20round=20?= =?UTF-8?q?2=20REVIEW-CLOSED=20=E2=80=94=20batch=20review=20+=20fix=20roun?= =?UTF-8?q?d=20+=20re-review=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index c4040ae6..5ae45097 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -773,6 +773,19 @@ read + canvas-centered dialogs). Remaining before shipment: the formal restore, A→B update swap, row I Linux), and the final-HEAD preflight re-run. +**Round REVIEW-CLOSED 2026-08-15:** the owed Opus dual-lens batch review +of the six round-2 commits returned PASS with 8 findings; the fix round +(`0baebce2` — headline: `RetailWaitDialogView` was the ONE dialog view the +EffectiveCanvasSize sweep missed, firing on ENTER; plus the two stale +deleted-mechanism doc assertions, the Confirmation `0xAC` property, +truncating input mapping, the IsCurrent world-name gate, the AD-98 +evidence note) closed all seven in the narrow re-review; F2 filed as +#401 (invert RetailUi to opt-out). The review also proved the +`DatWidgetFactory` justify widening has ZERO regressions across all 35 +layout fixtures (303 buttons swept; the 16 authored-Left all already +left-aligned via their face-child branch) and is a move TOWARD retail +(`CalcJustification @0x00467260` has no lifted-from-child condition). + Two real defects, both root-caused and fixed: 1. **`6e1c0967` — launcher-spawned clients had NO interface at all.** From 5eaad2c88c9eec8870f236c9e37030cf78157122 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:49:52 +0200 Subject: [PATCH 077/138] =?UTF-8?q?feat(net,runtime):=20Campaign=20CC=20CC?= =?UTF-8?q?2=20=E2=80=94=20CharacterCreate=20wire,=200xF643=20correlation,?= =?UTF-8?q?=20creation=20status=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire (Core.Net): - CharacterCreate.cs: outbound 0xF656 builder, byte-exact port of Proto_UI::SendCharGenResult@0x00546a70 -> ACCharGenResult::Pack@0x005c7570 -> CG_Pack@0x005c7200. Account String16L first (packed outside CG_Pack), then the constant-1 u32, heritage/gender, 14 appearance strip/style/color u32s, 6 f64 shades (skin/hair/headgear/shirt/trousers/footwear, retail order), template, 6 attributes, slot, classId, numSkills + exactly 55 u32 skill-advancement classes (ReadOnlySpan validated ==55, throws ArgumentException otherwise — ACE terminates the session on any other count via PlayerFactory.CreateResult.ClientServerSkillsMismatch), name String16L, startArea, isAdmin, isEnvoy, and a trailing checksum whose exact 19-term accumulation set (heritage+gender+3 strips+hairColor+ eyeColor+hairStyle+headgearStyle+shirtStyle+trousersStyle+footwearStyle+ template+6 attributes) is read byte-for-byte off CG_Pack's decompiled accumulator (0x005c7213-0x005c74c3) — headgearColor/shirtColor/ trousersColor/footwearColor/shades/slot/classId are deliberately absent from the sum despite sitting adjacent on the wire. Cross-checked against ACE's CharacterCreateInfo.Unpack/Appearance.Unpack and holtburger's CharacterCreateRequestData (types.rs:236-369), which agree on every field and order. Retail routes via SendToLogon — the same queue CharacterDelete already uses. - CharGenVerificationResponse.cs (new): promotes the shared 0xF643 parse out of CharacterRestore — full Code enum (Undef..AdminPrivilegeDenied=7, ACE's CharacterGenerationVerificationResponse) plus the conditional Ok-only identity payload (guid/String16L name/u32 secondsGreyedOut). CharacterRestore.Parse now delegates to it; CharacterRestore's public Parsed shape, Parse signature, and every existing test expectation are UNCHANGED. - PacketWriter.WriteDouble: f64 little-endian helper for the shade fields. WorldSession dispatch (Core.Net): - Added an awaiting-request latch (None/Restore/Create), armed by SendRestoreCharacter/the new SendCharacterCreation immediately before each send (SendCharacterCreation builds the body first so a skill-count throw never arms the latch for a request that was never sent), cleared the instant a matching 0xF643 is dispatched (success OR parse failure — a malformed reply must never wedge the latch open) and on Dispose. 0xF643 now routes to CharacterRestoreReceived or the new CharacterCreateResponseReceived (Action) by that latch; an unexpected 0xF643 with nothing outstanding logs once and is dropped, never misattributed. Fixed WorldSessionCharacterSelectionTests' restore-dispatch test, which previously fed a bare CharacterRestore response with no preceding SendRestoreCharacter — that shape is now the "no outstanding request" drop path by design. Status events (Runtime + Launcher.Core, contract first): - Amended docs/plans/2026-08-14-launcher-campaign.md §LA1's pinned status vocabulary to add characterCreated{guid,name} (Ok reply identity, named to mirror CharGenVerificationResponse's own fields and to read distinct from enteredWorld — retail logs a freshly created character straight in without a fresh characterList) and creationFailed{code,name} (raw Code value + its enum member name). - SessionStatusWriter.CharacterCreated/CreationFailed implement that contract. - Launcher.Core: CharacterCreatedStatusEvent/CreationFailedStatusEvent + StatusEventParser cases, in lockstep. Tests: CharacterCreateTests (byte-exact layout incl. checksum term-set, 55-slot fixture, wrong-count throws), CharGenVerificationResponseTests (every Code value), WorldSessionCharacterCreationTests (create-then- response routes correctly, restore unaffected, no-outstanding drop, second-response-after-consumed drop, Dispose clears the latch, a builder throw never arms it), SessionStatusWriterTests + Launcher.Core StatusEventParserTests/StatusFileTailerTests (pinned shape + tailer round-trip) for the two new events. Verified: dotnet build AcDream.slnx -c Release — 0 errors. Full solution test run green (Core.Net.Tests 993/993, Runtime.Tests 1667/1667, Launcher.Core.Tests 323/323, plus every other project in the solution). WSL Ubuntu: Core.Net.Tests 993/993, Runtime.Tests 1667/1667. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-14-launcher-campaign.md | 21 +- .../Messages/CharGenVerificationResponse.cs | 145 +++++++++ .../Messages/CharacterCreate.cs | 305 +++++++++++++++++ .../Messages/CharacterRestore.cs | 58 ++-- src/AcDream.Core.Net/Packets/PacketWriter.cs | 7 + src/AcDream.Core.Net/WorldSession.cs | 138 +++++++- .../Status/StatusEvent.cs | 30 ++ .../Status/StatusEventParser.cs | 38 +++ .../Session/SessionStatusWriter.cs | 41 +++ .../CharGenVerificationResponseTests.cs | 110 +++++++ .../Messages/CharacterCreateTests.cs | 308 ++++++++++++++++++ .../WorldSessionCharacterCreationTests.cs | 287 ++++++++++++++++ .../WorldSessionCharacterSelectionTests.cs | 7 + .../Status/StatusEventParserTests.cs | 29 ++ .../Status/StatusFileTailerTests.cs | 27 ++ .../Session/SessionStatusWriterTests.cs | 37 +++ 16 files changed, 1548 insertions(+), 40 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs create mode 100644 src/AcDream.Core.Net/Messages/CharacterCreate.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index c4040ae6..19e08478 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -170,6 +170,7 @@ line, writer opens `FileShare.Read`, tailer opens `enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`, `pluginFailed{plugin,error}`, `loginCommandFailed{commandIndex,command,error}`, +`characterCreated{guid,name}`, `creationFailed{code,name}`, `disconnected{reason}`, `exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"` (ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH @@ -177,6 +178,23 @@ sides. Unknown `e` values must parse to a typed Unknown event, never throw; a known `e` with a wrong payload shape should be distinguishable from an unknown `e` (LA3 review finding 12). +**Campaign CC CC2 amendment (this section is the contract; the writer and +tailer below implement it, in that order):** `characterCreated{guid,name}` +fires on the Ok reply to a `CharacterCreate` (opcode `0xF656`) request — +`guid`/`name` come straight off the shared `0xF643` +`CharGenVerificationResponse` Ok identity payload +(`AcDream.Core.Net.Messages.CharGenVerificationResponse`), deliberately +named `guid`/`name` rather than `characterId`/`characterName` to mirror +that payload's own field names and to read distinctly from +`enteredWorld` — a freshly created character is logged straight in by +retail without a fresh `characterList` (see that type's doc comment), so +`characterCreated` can precede an `enteredWorld` for the same character +rather than replacing it. `creationFailed{code,name}` fires on any non-Ok +reply: `code` is the raw wire `CharGenVerificationResponse.Code` value, +`name` is that code's enum member name (e.g. `"NameInUse"`) so a reader +gets a stable readable reason without hard-coding the numeric mapping +itself. + `loginCommandFailed.commandIndex` is the zero-based index in the configured `loginCommands` array. `command` is the exact configured line and `error` is the isolated parser/router/handler failure. The event is observational: the @@ -214,7 +232,8 @@ Three pieces, one slice, because they share the session-config/status seam: config; absent → permanent no-op sink). Versioned event vocabulary (`"v":1`): `started`, `connected`, `characterList`, `enteredWorld`, `pluginLoaded`/`pluginFailed`, - `loginCommandFailed`, `disconnected`, `exited`. + `loginCommandFailed`, `characterCreated`/`creationFailed` (Campaign CC + CC2), `disconnected`, `exited`. Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL sink with four kinds (lifecycle/failure/event/resources) and NO per-session file — the status writer is a second, separate sink, not a rework of the diff --git a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs new file mode 100644 index 00000000..b506c2bd --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs @@ -0,0 +1,145 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Shared parser for opcode 0xF643 — retail's +/// CharacterGenerationVerificationResponse shape, which BOTH +/// (opcode 0xF7D9 request) and +/// (opcode 0xF656 request) receive on +/// the exact same wire opcode — a genuine retail opcode reuse, confirmed by +/// ACE's own GameMessageOpcode.cs declaring both +/// CharacterCreateResponse = 0xF643 and +/// CharacterRestoreResponse = 0xF643, // This is a duplicate.... +/// +/// +/// Campaign CC CC2: this type is the promotion of the parse logic +/// that used to live only in (Campaign +/// LA slice LA7a). Character creation now exists (), +/// so the two message families that collide on this opcode are both real and +/// both need it — keeps its own +/// shape for source compatibility and +/// delegates to this type internally; new code (the create response, +/// WorldSession.CharacterCreateResponseReceived) consumes +/// directly. A caller cannot tell "restore response" +/// from "create response" by opcode or shape alone — WorldSession +/// disambiguates by tracking which outbound request (restore vs. create) it +/// is awaiting a reply to (see WorldSession's awaiting-request latch). +/// +/// +/// +/// Wire layout, verbatim from ACE's GameMessageCharacterCreateResponse.cs +/// / GameMessageCharacterRestore.cs (both write the identical shape) +/// and cross-checked against holtburger's +/// CharacterCreateResponseData::unpack +/// (holtburger-protocol/src/messages/character/types.rs:379-410): +/// +/// +/// +/// u32 opcode (0xF643) +/// u32 code (CharacterGenerationVerificationResponse) +/// -- only when code == Ok -- +/// u32 guid +/// String16L name +/// u32 secondsGreyedOut +/// +/// +/// +/// is a verbatim port of ACE's +/// CharacterGenerationVerificationResponse enum +/// (ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs), +/// which is itself retail's own dialog dispatch table +/// (Handle_CharGenVerificationResponse@0x0055E8B0): NameInUse → +/// ID_Character_Err_NameReserved, NameBanned → +/// ID_Character_Err_NameBanned, Corrupt/DatabaseDown → +/// ID_Character_Err_NameDBDown, AdminPrivilegeDenied → +/// ID_Character_Err_NameAdminDenied. Pending/Undef +/// retail treats as a silent state reset with no dialog — notably ACE sends +/// Pending for a disabled-Olthoi rejection +/// (CharacterHandler.CharacterCreateEx, +/// olthoi_play_disabled branch), so that specific rejection is +/// invisible to the retail-faithful client too; this is a retail quirk to +/// port as-is, not a bug to fix. Dialog presentation itself is CC5's job +/// (App layer), not this Core.Net type's. +/// +/// +public static class CharGenVerificationResponse +{ + public const uint ResponseOpcode = 0xF643u; + + /// + /// Verbatim port of ACE's CharacterGenerationVerificationResponse + /// enum, which is retail's own Handle_CharGenVerificationResponse + /// dispatch table. + /// + public enum Code : uint + { + Undef = 0, + Ok = 1, + Pending = 2, + NameInUse = 3, + NameBanned = 4, + Corrupt = 5, + DatabaseDown = 6, + AdminPrivilegeDenied = 7, + } + + /// + /// Parsed 0xF643 body. , , and + /// are only populated when + /// equals — retail omits + /// them entirely on the wire otherwise (both + /// GameMessageCharacterCreateResponse and + /// GameMessageCharacterRestore gate the trailing fields on + /// response == ... .Ok). + /// + public readonly record struct Parsed( + uint RawCode, + uint? Guid, + string? Name, + uint? SecondsGreyedOut) + { + /// + /// Best-effort named view of . A plain enum + /// cast never throws in C#, so this is safe even for a value retail + /// never defined — always trust as the source + /// of truth. + /// + public Code AsCode => (Code)RawCode; + + /// True when the trailing identity fields are present. + public bool IsOk => RawCode == (uint)Code.Ok; + } + + /// + /// Parse a 0xF643 body. must start with + /// the 4-byte opcode. + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != ResponseOpcode) + throw new FormatException( + $"expected CharacterGenerationVerificationResponse opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}"); + + uint rawCode = ReadU32(body, ref pos); + if (rawCode != (uint)Code.Ok) + return new Parsed(rawCode, null, null, null); + + uint guid = ReadU32(body, ref pos); + string name = StringReader.ReadString16L(body, ref pos); + uint secondsGreyedOut = ReadU32(body, ref pos); + + return new Parsed(rawCode, guid, name, secondsGreyedOut); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/src/AcDream.Core.Net/Messages/CharacterCreate.cs b/src/AcDream.Core.Net/Messages/CharacterCreate.cs new file mode 100644 index 00000000..ca008060 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterCreate.cs @@ -0,0 +1,305 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-creation request (opcode 0xF656). Campaign CC +/// slice CC2 — the outbound half of retail creation; the shared 0xF643 +/// response is (see that type's doc +/// comment for the two-family opcode collision with +/// , and WorldSession's awaiting-request +/// latch for how the two are disambiguated on receipt). +/// +/// +/// Wire layout ported byte-for-byte from +/// Proto_UI::SendCharGenResult@0x00546a70 (packs the account name, +/// then calls ACCharGenResult::Pack@0x005c7570 → +/// ACCharGenResult::CG_Pack@0x005c7200) and cross-checked against +/// ACE's CharacterCreateInfo.Unpack / Appearance.Unpack +/// (ACE.Entity/CharacterCreateInfo.cs, ACE.Entity/Appearance.cs) +/// and holtburger's CharacterCreateRequestData +/// (holtburger-protocol/src/messages/character/types.rs:236-369), +/// which agree on every field and its order: +/// +/// +/// +/// u32 opcode (0xF656) +/// String16L accountName (packed OUTSIDE CG_Pack, by SendCharGenResult itself) +/// -- ACCharGenResult::CG_Pack body -- +/// u32 constant (always 1 — CG_Pack@0x005c7208) +/// u32 heritage +/// u32 gender +/// u32 eyesStrip +/// u32 noseStrip +/// u32 mouthStrip +/// u32 hairColor +/// u32 eyeColor +/// u32 hairStyle +/// u32 headgearStyle +/// u32 headgearColor +/// u32 shirtStyle +/// u32 shirtColor +/// u32 trousersStyle +/// u32 trousersColor +/// u32 footwearStyle +/// u32 footwearColor +/// f64 skinShade +/// f64 hairShade +/// f64 headgearShade +/// f64 shirtShade +/// f64 trousersShade +/// f64 footwearShade +/// u32 template +/// u32 strength +/// u32 endurance +/// u32 coordination +/// u32 quickness +/// u32 focus +/// u32 self +/// u32 slot (ACE: CharacterSlot — NOT the character guid) +/// u32 classId +/// u32 numSkills (MUST be exactly ) +/// u32[] skillAdvancementClasses (numSkills entries) +/// String16L name +/// u32 startArea +/// u32 isAdmin +/// u32 isEnvoy (ACE: IsSentinel) +/// u32 checksum (see ) +/// +/// +/// +/// The 55-slot invariant. ACE's PlayerFactory.Create +/// (reached from CharacterHandler.CharacterCreateEx) rejects a +/// client/server skill-table mismatch by TERMINATING the session +/// (PlayerFactory.CreateResult.ClientServerSkillsMismatch → +/// session.Terminate(SessionTerminationReason.ClientVersionIncorrect, ...)) +/// — there is no graceful recovery from sending the wrong count. Retail's +/// live skill table has exactly +/// (55) skills, so takes +/// skillAdvancementClasses as a and +/// throws for any length other than 55 — +/// structurally impossible to send anything else through this builder. +/// +/// +/// +/// The trailing checksum. Retail computes and sends it +/// (CG_Pack@0x005c74c3, the final *(uint32_t*)ecx_33 = +/// (ebx_18 + self) store); ACE's CharacterCreateInfo.Unpack never +/// reads it (the reader consumes isSentinel and stops — see +/// ACE.Entity/CharacterCreateInfo.cs:67) and holtburger's +/// CharacterCreateRequestData::unpack agrees (its field list ends at +/// is_sentinel, no checksum read). We compute and send it anyway for +/// byte fidelity with a genuine retail client. Decompiled accumulation +/// order (CG_Pack@0x005c7213-0x005c74c3) sums EXACTLY: +/// heritage, gender, the three appearance strips (eyes/nose/mouth), +/// hairColor, eyeColor, hairStyle, headgearStyle, shirtStyle, trousersStyle, +/// footwearStyle, template, and the six attributes (strength through self). +/// Notably ABSENT from the sum despite being adjacent fields on the wire: +/// headgearColor, shirtColor, trousersColor, footwearColor, all six f64 +/// shades, slot, and classId — mirrors that +/// exact (and exactly that) field set. u32 addition is commutative and +/// associative modulo 2^32, so summation order does not affect the result; +/// orders the terms for readability, not +/// wire fidelity. +/// +/// +/// +/// Routing. Proto_UI::SendCharGenResult sends via +/// Proto_UI::SendToLogon@0x00546b03 — the SAME queue as +/// 's request +/// (Proto_UI::SendDeleteCharacter@0x00546b83, also SendToLogon) +/// and CharacterEnterWorld's request +/// (Proto_UI::SendEnterWorld@0x00546c12). WorldSession's outbound +/// helper, SendCharacterCreation, sends on +/// GameMessageGroup.LoginQueue — the same queue +/// WorldSession.SendDeleteCharacter already uses. +/// +/// +/// +/// Account-name gate. ACE's CharacterCreate handler +/// (CharacterHandler.cs:27-32) silently drops the request when the +/// packed account name doesn't match session.Account — the same +/// silent-no-reply shape 's doc comment already +/// warns about for restore. WorldSession's awaiting-request latch +/// must never assume a reply is coming. +/// +/// +public static class CharacterCreate +{ + public const uint Opcode = 0xF656u; + + /// + /// Retail's live skill-advancement-class table size. ACE terminates the + /// session on any other count — see the class doc comment. + /// + public const int SkillAdvancementClassCount = 55; + + /// + /// The fourteen style/color strip fields plus the six f64 shade fields — + /// Appearance.Unpack's exact field set and order + /// (ACE.Entity/Appearance.cs). + /// + public readonly record struct Appearance( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairColor, + uint EyeColor, + uint HairStyle, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade); + + /// The six primary attributes, retail's fixed str/end/coord/quick/focus/self order. + public readonly record struct Attributes( + uint Strength, + uint Endurance, + uint Coordination, + uint Quickness, + uint Focus, + uint Self); + + /// + /// Every field of an outbound CharacterCreate EXCEPT the account name + /// (a separate parameter, packed outside + /// CG_Pack — see the class doc comment) and the skill-advancement + /// array (a parameter so its length is + /// validated at the call site rather than smuggled through a record + /// field of unbounded size). + /// + public readonly record struct Request( + uint Heritage, + uint Gender, + Appearance Appearance, + uint Template, + Attributes Attributes, + uint Slot, + uint ClassId, + string Name, + uint StartArea, + bool IsAdmin, + bool IsEnvoy); + + /// + /// Build the body bytes for an outbound CharacterCreate request. + /// See the class doc comment for the exact byte layout. + /// + /// + /// .Length is not exactly + /// — ACE terminates the session + /// on any other count, so this builder refuses to construct the request + /// at all rather than send something retail-invalid. + /// + public static byte[] BuildRequestBody( + string accountName, + Request request, + ReadOnlySpan skillAdvancementClasses) + { + ArgumentNullException.ThrowIfNull(accountName); + ArgumentNullException.ThrowIfNull(request.Name); + if (skillAdvancementClasses.Length != SkillAdvancementClassCount) + { + throw new ArgumentException( + "retail's CG_Pack numSkills must be exactly " + + $"{SkillAdvancementClassCount} — ACE terminates the session " + + "(PlayerFactory.CreateResult.ClientServerSkillsMismatch) on " + + $"any other count. Got {skillAdvancementClasses.Length}.", + nameof(skillAdvancementClasses)); + } + + Appearance appearance = request.Appearance; + Attributes attributes = request.Attributes; + + var w = new PacketWriter( + 256 + (skillAdvancementClasses.Length * 4) + (request.Name.Length * 2)); + w.WriteUInt32(Opcode); + w.WriteString16L(accountName); + + // -- ACCharGenResult::CG_Pack body -- + w.WriteUInt32(1u); // CG_Pack@0x005c7208 constant + w.WriteUInt32(request.Heritage); + w.WriteUInt32(request.Gender); + w.WriteUInt32(appearance.EyesStrip); + w.WriteUInt32(appearance.NoseStrip); + w.WriteUInt32(appearance.MouthStrip); + w.WriteUInt32(appearance.HairColor); + w.WriteUInt32(appearance.EyeColor); + w.WriteUInt32(appearance.HairStyle); + w.WriteUInt32(appearance.HeadgearStyle); + w.WriteUInt32(appearance.HeadgearColor); + w.WriteUInt32(appearance.ShirtStyle); + w.WriteUInt32(appearance.ShirtColor); + w.WriteUInt32(appearance.TrousersStyle); + w.WriteUInt32(appearance.TrousersColor); + w.WriteUInt32(appearance.FootwearStyle); + w.WriteUInt32(appearance.FootwearColor); + w.WriteDouble(appearance.SkinShade); + w.WriteDouble(appearance.HairShade); + w.WriteDouble(appearance.HeadgearShade); + w.WriteDouble(appearance.ShirtShade); + w.WriteDouble(appearance.TrousersShade); + w.WriteDouble(appearance.FootwearShade); + w.WriteUInt32(request.Template); + w.WriteUInt32(attributes.Strength); + w.WriteUInt32(attributes.Endurance); + w.WriteUInt32(attributes.Coordination); + w.WriteUInt32(attributes.Quickness); + w.WriteUInt32(attributes.Focus); + w.WriteUInt32(attributes.Self); + w.WriteUInt32(request.Slot); + w.WriteUInt32(request.ClassId); + w.WriteUInt32((uint)skillAdvancementClasses.Length); + foreach (uint skill in skillAdvancementClasses) + w.WriteUInt32(skill); + w.WriteString16L(request.Name); + w.WriteUInt32(request.StartArea); + w.WriteUInt32(request.IsAdmin ? 1u : 0u); + w.WriteUInt32(request.IsEnvoy ? 1u : 0u); + w.WriteUInt32(ComputeChecksum(request)); + + return w.ToArray(); + } + + /// + /// Retail's trailing checksum field — see the class doc comment for the + /// exact decompiled accumulation and the fields deliberately absent from + /// it. ACE never reads this field; acdream sends it for byte fidelity + /// with a genuine retail client. + /// + public static uint ComputeChecksum(Request request) + { + Appearance a = request.Appearance; + Attributes b = request.Attributes; + return unchecked( + request.Heritage + + request.Gender + + a.EyesStrip + + a.NoseStrip + + a.MouthStrip + + a.HairColor + + a.EyeColor + + a.HairStyle + + a.HeadgearStyle + + a.ShirtStyle + + a.TrousersStyle + + a.FootwearStyle + + request.Template + + b.Strength + + b.Endurance + + b.Coordination + + b.Quickness + + b.Focus + + b.Self); + } +} diff --git a/src/AcDream.Core.Net/Messages/CharacterRestore.cs b/src/AcDream.Core.Net/Messages/CharacterRestore.cs index 794cc50d..8d3caf90 100644 --- a/src/AcDream.Core.Net/Messages/CharacterRestore.cs +++ b/src/AcDream.Core.Net/Messages/CharacterRestore.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using AcDream.Core.Net.Packets; namespace AcDream.Core.Net.Messages; @@ -75,11 +74,28 @@ namespace AcDream.Core.Net.Messages; /// fields are read only when verificationFlag == 1. Because the two /// message families are wire-identical when they collide, a caller cannot /// tell "restore response" from "create response" by opcode or shape -/// alone — it must track which outbound request (this file's -/// vs. a future CharacterCreate) it is -/// awaiting a reply to. Character creation is out of this campaign's scope -/// (design spec §7 non-goals); this type does not attempt to disambiguate -/// the two families itself. +/// alone — it must track which outbound request +/// ( vs. +/// ) +/// it is awaiting a reply to. +/// +/// +/// +/// Campaign CC CC2 update: character creation now exists +/// (), so the +/// disambiguation this doc comment used to defer is real work now, done by +/// WorldSession's awaiting-request latch (set by +/// WorldSession.SendRestoreCharacter / +/// WorldSession.SendCharacterCreation, cleared on the matching +/// response), which routes each 0xF643 to +/// WorldSession.CharacterRestoreReceived or +/// WorldSession.CharacterCreateResponseReceived accordingly and drops +/// (rather than misattributes) a 0xF643 with no outstanding request. The +/// wire parse itself is now shared: delegates to +/// , which both families +/// consume. This type's own shape and +/// signature are UNCHANGED by that refactor — every existing caller and test +/// keeps working exactly as before. /// /// public static class CharacterRestore @@ -119,32 +135,14 @@ public static class CharacterRestore /// /// Parse a CharacterRestore response body (opcode 0xF643). - /// must start with the 4-byte opcode. + /// must start with the 4-byte opcode. Delegates + /// to the shared (Campaign + /// CC CC2); this type's shape and this method's + /// exception behavior are unchanged from before that refactor. /// public static Parsed Parse(ReadOnlySpan body) { - int pos = 0; - - uint opcode = ReadU32(body, ref pos); - if (opcode != ResponseOpcode) - throw new FormatException($"expected CharacterRestore response opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}"); - - uint verificationFlag = ReadU32(body, ref pos); - if (verificationFlag != 1u) - return new Parsed(verificationFlag, null, null, null); - - uint guid = ReadU32(body, ref pos); - string name = StringReader.ReadString16L(body, ref pos); - uint secondsGreyedOut = ReadU32(body, ref pos); - - return new Parsed(verificationFlag, guid, name, secondsGreyedOut); - } - - private static uint ReadU32(ReadOnlySpan source, ref int pos) - { - if (source.Length - pos < 4) throw new FormatException("truncated u32"); - uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); - pos += 4; - return value; + CharGenVerificationResponse.Parsed shared = CharGenVerificationResponse.Parse(body); + return new Parsed(shared.RawCode, shared.Guid, shared.Name, shared.SecondsGreyedOut); } } diff --git a/src/AcDream.Core.Net/Packets/PacketWriter.cs b/src/AcDream.Core.Net/Packets/PacketWriter.cs index f7edd92a..54e633a1 100644 --- a/src/AcDream.Core.Net/Packets/PacketWriter.cs +++ b/src/AcDream.Core.Net/Packets/PacketWriter.cs @@ -95,6 +95,13 @@ public sealed class PacketWriter _position += 4; } + public void WriteDouble(double value) + { + EnsureCapacity(8); + BinaryPrimitives.WriteDoubleLittleEndian(_buffer.AsSpan(_position), value); + _position += 8; + } + /// Pad with zeros so the buffer length is a multiple of 4. public void AlignTo4() { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 04d4397f..9d2397b9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -598,6 +598,16 @@ public sealed class WorldSession : IDisposable public event Action? CharacterListReceived; public event Action? CharacterDeleteAcknowledged; public event Action? CharacterRestoreReceived; + /// + /// Campaign CC CC2: fires when a 0xF643 + /// () response arrives while + /// this session's awaiting-request latch says Create — i.e. the + /// reply to . See + /// 's doc comment for the + /// opcode collision with and how + /// the two are disambiguated. + /// + public event Action? CharacterCreateResponseReceived; public event Action? CharacterErrorReceived; /// /// Campaign LA gate round 2 finding 3: ACE sends this in the same batch @@ -706,6 +716,41 @@ public sealed class WorldSession : IDisposable public ServerName.Parsed? ServerInfo { get; private set; } private CharacterError.Parsed? _lastCharacterSelectionError; + /// + /// Campaign CC CC2: which outbound character-generation request (if any) + /// this session is awaiting a 0xF643 + /// () reply to. Restore and + /// create requests share that opcode on the wire (see + /// 's doc comment) with no + /// self-describing discriminant, so this latch is the only thing that + /// tells the dispatcher which event to fire. Set by + /// / + /// immediately before the send; cleared the moment a matching 0xF643 is + /// dispatched (success OR parse failure — a malformed reply must not + /// wedge the latch open forever) and on session teardown + /// (). Read/written only from the caller's frame + /// thread — the same single-threaded invariant every other per-session + /// field here (e.g. ) relies + /// on; is never invoked concurrently with + /// a send (see the class doc comment's thread-id probe note). + /// + private enum PendingCharGenVerificationRequest + { + None, + Restore, + Create, + } + + private PendingCharGenVerificationRequest _pendingCharGenVerification = + PendingCharGenVerificationRequest.None; + + /// + /// One-shot guard so an unexpected 0xF643 (no outstanding create/restore + /// request) logs exactly once per session rather than spamming on a + /// misbehaving or replaying server. + /// + private bool _loggedUnexpectedCharGenVerificationResponse; + private readonly IWorldSessionTransport _net; private long _lastInboundPacketTicks = Stopwatch.GetTimestamp(); private long _lastPingRequestTicks; @@ -1823,18 +1868,56 @@ public sealed class WorldSession : IDisposable { CharacterDeleteAcknowledged?.Invoke(); } - else if (op == CharacterRestore.ResponseOpcode) + else if (op == CharGenVerificationResponse.ResponseOpcode) { - CharacterRestore.Parsed parsed; - try - { - parsed = CharacterRestore.Parse(body); - } - catch + // Campaign CC CC2: this opcode is a genuine retail reuse + // between CharacterRestore and CharacterCreate responses + // (see CharGenVerificationResponse's doc comment) — the + // awaiting-request latch is the only thing that tells us + // which family a given 0xF643 belongs to. Clear it before + // parsing (not after) so a malformed reply can never leave + // the latch stuck open, awaiting a response that will now + // never come and misattributing whatever arrives next. + PendingCharGenVerificationRequest awaited = _pendingCharGenVerification; + if (awaited == PendingCharGenVerificationRequest.None) { + if (!_loggedUnexpectedCharGenVerificationResponse) + { + _loggedUnexpectedCharGenVerificationResponse = true; + Console.Error.WriteLine( + "[session] unexpected CharacterGenerationVerificationResponse " + + "(0xF643) with no outstanding create/restore request — dropped."); + } continue; } - CharacterRestoreReceived?.Invoke(parsed); + _pendingCharGenVerification = PendingCharGenVerificationRequest.None; + + if (awaited == PendingCharGenVerificationRequest.Restore) + { + CharacterRestore.Parsed parsed; + try + { + parsed = CharacterRestore.Parse(body); + } + catch + { + continue; + } + CharacterRestoreReceived?.Invoke(parsed); + } + else + { + CharGenVerificationResponse.Parsed parsed; + try + { + parsed = CharGenVerificationResponse.Parse(body); + } + catch + { + continue; + } + CharacterCreateResponseReceived?.Invoke(parsed); + } } else if (op == CharacterError.Opcode) { @@ -2223,9 +2306,39 @@ public sealed class WorldSession : IDisposable /// /// Send retail CharacterRestore through the control queue. This is /// deliberately non-blocking because ACE silently drops unknown guids. + /// Arms the awaiting-request latch as Restore BEFORE the send so + /// a reply that arrives on a later Tick is never misattributed to a + /// different request (Campaign CC CC2). /// - public void SendRestoreCharacter(uint characterId) => + public void SendRestoreCharacter(uint characterId) + { + _pendingCharGenVerification = PendingCharGenVerificationRequest.Restore; SendControlMessage(CharacterRestore.BuildRequestBody(characterId)); + } + + /// + /// Send retail CharacterCreate (opcode 0xF656) through the + /// login/logon queue — Proto_UI::SendCharGenResult routes via + /// SendToLogon, the same queue + /// uses (see + /// 's class doc comment). Deliberately + /// non-blocking, matching — ACE + /// silently drops a request whose packed account name doesn't match the + /// session's own account. Arms the awaiting-request latch as + /// Create BEFORE the send (Campaign CC CC2). + /// + public void SendCharacterCreation( + string accountName, + CharacterCreate.Request request, + ReadOnlySpan skillAdvancementClasses) + { + byte[] body = CharacterCreate.BuildRequestBody( + accountName, + request, + skillAdvancementClasses); + _pendingCharGenVerification = PendingCharGenVerificationRequest.Create; + SendGameMessage(body, GameMessageGroup.LoginQueue); + } /// /// Phase I.3: test-only hook. When non-null, @@ -3177,6 +3290,13 @@ public sealed class WorldSession : IDisposable if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; + // Campaign CC CC2: a teardown mid-flight must not leave a stale + // Restore/Create latch behind it — this session object is never + // reused (a fresh WorldSession is constructed per connection + // attempt), but clearing here keeps the invariant "no outstanding + // request survives teardown" true rather than merely true-in-practice. + _pendingCharGenVerification = PendingCharGenVerificationRequest.None; + SessionShutdownPlan shutdown = BuildShutdownPlan( CurrentState, _transportNegotiated, diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index 2efbf5a9..2274baa8 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -44,6 +44,36 @@ public sealed record EnteredWorldStatusEvent : StatusEvent public required string CharacterName { get; init; } } +/// +/// Campaign CC CC2: the Ok reply to an outbound CharacterCreate (opcode +/// 0xF656). / mirror the shared +/// 0xF643 CharGenVerificationResponse Ok identity payload's own +/// field names — deliberately distinct from 's +/// characterId/characterName, since retail logs a freshly +/// created character straight in without a fresh characterList, so +/// this event can precede an for the +/// same character rather than replace it. +/// +public sealed record CharacterCreatedStatusEvent : StatusEvent +{ + public required uint Guid { get; init; } + + public required string Name { get; init; } +} + +/// +/// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate. +/// is the raw wire +/// CharGenVerificationResponse.Code value; is that +/// code's enum member name (e.g. "NameInUse"). +/// +public sealed record CreationFailedStatusEvent : StatusEvent +{ + public required uint Code { get; init; } + + public required string Name { get; init; } +} + public sealed record PluginLoadedStatusEvent : StatusEvent { public required string Plugin { get; init; } diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index 4f5000da..9967613d 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -103,6 +103,10 @@ public static class StatusEventParser ParsePluginFailed(root, v, e, t, sessionId), "loginCommandFailed" => ParseLoginCommandFailed(root, v, e, t, sessionId), + "characterCreated" => + ParseCharacterCreated(root, v, e, t, sessionId), + "creationFailed" => + ParseCreationFailed(root, v, e, t, sessionId), "disconnected" => ParseDisconnected(root, v, e, t, sessionId), "exited" => @@ -131,6 +135,8 @@ public static class StatusEventParser "pluginLoaded" or "pluginFailed" or "loginCommandFailed" or + "characterCreated" or + "creationFailed" or "disconnected" or "exited"; @@ -237,6 +243,38 @@ public static class StatusEventParser CharacterName = RequireString(root, "characterName"), }; + private static StatusEvent ParseCharacterCreated( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new CharacterCreatedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Guid = RequireUInt32(root, "guid"), + Name = RequireString(root, "name"), + }; + + private static StatusEvent ParseCreationFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new CreationFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Code = RequireUInt32(root, "code"), + Name = RequireString(root, "name"), + }; + private static StatusEvent ParsePluginLoaded( JsonElement root, int v, diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index c5a01d95..e0ce20d0 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -207,6 +207,47 @@ public sealed class SessionStatusWriter characterName, }); + /// + /// Campaign CC CC2: the retail 0xF643 Ok response to an outbound + /// CharacterCreate — see + /// AcDream.Core.Net.Messages.CharGenVerificationResponse. + /// and come straight off that response's Ok + /// identity payload. This is a distinct event from : + /// retail logs a freshly created character straight in without a fresh + /// CharacterList (see the shared response type's doc comment), so a + /// caller can expect this event to precede an eventual + /// for the same character, not replace it. + /// + public void CharacterCreated(string sessionId, uint guid, string name) => + Write(new + { + v = VocabularyVersion, + e = "characterCreated", + t = Now(), + sessionId, + guid, + name, + }); + + /// + /// Campaign CC CC2: a non-Ok 0xF643 response to an outbound + /// CharacterCreate. is the raw wire + /// CharGenVerificationResponse.Code value; + /// is that code's enum member name (e.g. "NameInUse") so a + /// launcher can render a readable reason without hard-coding the + /// server's numeric-to-dialog mapping itself. + /// + public void CreationFailed(string sessionId, uint code, string name) => + Write(new + { + v = VocabularyVersion, + e = "creationFailed", + t = Now(), + sessionId, + code, + name, + }); + public void PluginLoaded(string sessionId, string plugin) => Write(new { diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs new file mode 100644 index 00000000..5ce2a78b --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharGenVerificationResponseTests.cs @@ -0,0 +1,110 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign CC CC2: the shared 0xF643 parser both CharacterRestore and +/// CharacterCreate responses consume. See +/// for the pre-existing CharacterRestore-shaped coverage that must survive +/// this type's promotion unchanged. +/// +public sealed class CharGenVerificationResponseTests +{ + [Fact] + public void Parse_Ok_PopulatesIdentityPayload() + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write((uint)CharGenVerificationResponse.Code.Ok) + .WriteGuid(0x5000000Bu) + .WriteString16L("+NewChar") + .Write(0u); + + CharGenVerificationResponse.Parsed parsed = + CharGenVerificationResponse.Parse(w.ToArray()); + + Assert.Equal(1u, parsed.RawCode); + Assert.Equal(CharGenVerificationResponse.Code.Ok, parsed.AsCode); + Assert.True(parsed.IsOk); + Assert.Equal(0x5000000Bu, parsed.Guid); + Assert.Equal("+NewChar", parsed.Name); + Assert.Equal(0u, parsed.SecondsGreyedOut); + } + + [Theory] + [InlineData(0u, CharGenVerificationResponse.Code.Undef)] + [InlineData(2u, CharGenVerificationResponse.Code.Pending)] + [InlineData(3u, CharGenVerificationResponse.Code.NameInUse)] + [InlineData(4u, CharGenVerificationResponse.Code.NameBanned)] + [InlineData(5u, CharGenVerificationResponse.Code.Corrupt)] + [InlineData(6u, CharGenVerificationResponse.Code.DatabaseDown)] + [InlineData(7u, CharGenVerificationResponse.Code.AdminPrivilegeDenied)] + public void Parse_EveryNonOkCode_IsFlagOnlyWithNullTrailingFields( + uint rawCode, + CharGenVerificationResponse.Code expectedCode) + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(rawCode); + + CharGenVerificationResponse.Parsed parsed = + CharGenVerificationResponse.Parse(w.ToArray()); + + Assert.Equal(rawCode, parsed.RawCode); + Assert.Equal(expectedCode, parsed.AsCode); + Assert.False(parsed.IsOk); + Assert.Null(parsed.Guid); + Assert.Null(parsed.Name); + Assert.Null(parsed.SecondsGreyedOut); + } + + [Fact] + public void Parse_UnknownCode_NeverThrowsOnTheCast() + { + // A plain enum cast never throws in C# — a private-server or future + // retail revision sending a code we haven't named yet must not crash + // the parser. + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(99u); + + CharGenVerificationResponse.Parsed parsed = + CharGenVerificationResponse.Parse(w.ToArray()); + + Assert.Equal(99u, parsed.RawCode); + Assert.Equal((CharGenVerificationResponse.Code)99u, parsed.AsCode); + Assert.False(parsed.IsOk); + } + + [Fact] + public void Parse_WrongOpcode_Throws() + { + byte[] bytes = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu); + + Assert.Throws(() => CharGenVerificationResponse.Parse(bytes)); + } + + [Fact] + public void Parse_TruncatedAfterCode_Throws() + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write((uint)CharGenVerificationResponse.Code.Ok); + + Assert.Throws(() => CharGenVerificationResponse.Parse(w.ToArray())); + } + + [Fact] + public void Parse_TruncatedBeforeCode_Throws() + { + var w = AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode); + + Assert.Throws(() => CharGenVerificationResponse.Parse(w.ToArray())); + } + + [Fact] + public void ResponseOpcode_MatchesCharacterRestoresResponseOpcode() + { + // The whole point of this type: both families collide on the exact + // same wire opcode. + Assert.Equal(CharacterRestore.ResponseOpcode, CharGenVerificationResponse.ResponseOpcode); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs new file mode 100644 index 00000000..8080df08 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs @@ -0,0 +1,308 @@ +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign CC CC2: byte-exact coverage for the outbound CharacterCreate +/// (0xF656) builder — field order, the 55-slot skill-advancement invariant, +/// and the trailing checksum's exact retail accumulation set (see +/// 's class doc comment for the decompiled +/// source of truth). +/// +public sealed class CharacterCreateTests +{ + private static uint[] MakeSkills(uint seed = 0) + { + var skills = new uint[CharacterCreate.SkillAdvancementClassCount]; + for (int i = 0; i < skills.Length; i++) + skills[i] = seed + (uint)i; + return skills; + } + + private static CharacterCreate.Request MakeRequest() => new( + Heritage: 1u, + Gender: 0u, + Appearance: new CharacterCreate.Appearance( + EyesStrip: 2u, + NoseStrip: 3u, + MouthStrip: 4u, + HairColor: 5u, + EyeColor: 6u, + HairStyle: 7u, + HeadgearStyle: 8u, + HeadgearColor: 9u, + ShirtStyle: 10u, + ShirtColor: 11u, + TrousersStyle: 12u, + TrousersColor: 13u, + FootwearStyle: 14u, + FootwearColor: 15u, + SkinShade: 0.1, + HairShade: 0.2, + HeadgearShade: 0.3, + ShirtShade: 0.4, + TrousersShade: 0.5, + FootwearShade: 0.6), + Template: 16u, + Attributes: new CharacterCreate.Attributes( + Strength: 17u, + Endurance: 18u, + Coordination: 19u, + Quickness: 20u, + Focus: 21u, + Self: 22u), + Slot: 0u, + ClassId: 1u, + Name: "Testcdream", + StartArea: 23u, + IsAdmin: false, + IsEnvoy: false); + + [Fact] + public void BuildRequestBody_Layout_MatchesRetailCGPackFieldOrder() + { + CharacterCreate.Request request = MakeRequest(); + uint[] skills = MakeSkills(); + byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, skills); + + int pos = 0; + uint ReadU32() + { + uint v = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); + pos += 4; + return v; + } + double ReadF64() + { + double v = BinaryPrimitives.ReadDoubleLittleEndian(body.AsSpan(pos)); + pos += 8; + return v; + } + string ReadString16L() + { + ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos)); + pos += 2; + string s = Encoding.ASCII.GetString(body, pos, len); + pos += len; + int recordSize = 2 + len; + int padding = (4 - (recordSize & 3)) & 3; + pos += padding; + return s; + } + + Assert.Equal(CharacterCreate.Opcode, ReadU32()); + Assert.Equal("testaccount", ReadString16L()); + Assert.Equal(1u, ReadU32()); // CG_Pack@0x005c7208 constant + Assert.Equal(request.Heritage, ReadU32()); + Assert.Equal(request.Gender, ReadU32()); + Assert.Equal(request.Appearance.EyesStrip, ReadU32()); + Assert.Equal(request.Appearance.NoseStrip, ReadU32()); + Assert.Equal(request.Appearance.MouthStrip, ReadU32()); + Assert.Equal(request.Appearance.HairColor, ReadU32()); + Assert.Equal(request.Appearance.EyeColor, ReadU32()); + Assert.Equal(request.Appearance.HairStyle, ReadU32()); + Assert.Equal(request.Appearance.HeadgearStyle, ReadU32()); + Assert.Equal(request.Appearance.HeadgearColor, ReadU32()); + Assert.Equal(request.Appearance.ShirtStyle, ReadU32()); + Assert.Equal(request.Appearance.ShirtColor, ReadU32()); + Assert.Equal(request.Appearance.TrousersStyle, ReadU32()); + Assert.Equal(request.Appearance.TrousersColor, ReadU32()); + Assert.Equal(request.Appearance.FootwearStyle, ReadU32()); + Assert.Equal(request.Appearance.FootwearColor, ReadU32()); + Assert.Equal(request.Appearance.SkinShade, ReadF64()); + Assert.Equal(request.Appearance.HairShade, ReadF64()); + Assert.Equal(request.Appearance.HeadgearShade, ReadF64()); + Assert.Equal(request.Appearance.ShirtShade, ReadF64()); + Assert.Equal(request.Appearance.TrousersShade, ReadF64()); + Assert.Equal(request.Appearance.FootwearShade, ReadF64()); + Assert.Equal(request.Template, ReadU32()); + Assert.Equal(request.Attributes.Strength, ReadU32()); + Assert.Equal(request.Attributes.Endurance, ReadU32()); + Assert.Equal(request.Attributes.Coordination, ReadU32()); + Assert.Equal(request.Attributes.Quickness, ReadU32()); + Assert.Equal(request.Attributes.Focus, ReadU32()); + Assert.Equal(request.Attributes.Self, ReadU32()); + Assert.Equal(request.Slot, ReadU32()); + Assert.Equal(request.ClassId, ReadU32()); + uint numSkills = ReadU32(); + Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, numSkills); + for (int i = 0; i < skills.Length; i++) + Assert.Equal(skills[i], ReadU32()); + Assert.Equal(request.Name, ReadString16L()); + Assert.Equal(request.StartArea, ReadU32()); + Assert.Equal(0u, ReadU32()); // isAdmin + Assert.Equal(0u, ReadU32()); // isEnvoy + uint checksum = ReadU32(); + Assert.Equal(CharacterCreate.ComputeChecksum(request), checksum); + Assert.Equal(pos, body.Length); + } + + [Fact] + public void BuildRequestBody_ExactByteSequence_ShortAccountAndName() + { + // Minimal fixture with distinct short strings, hand-checked padding. + CharacterCreate.Request request = new( + Heritage: 1u, + Gender: 0u, + Appearance: default, + Template: 0u, + Attributes: default, + Slot: 0u, + ClassId: 1u, + Name: "ab", + StartArea: 0u, + IsAdmin: false, + IsEnvoy: false); + uint[] skills = new uint[CharacterCreate.SkillAdvancementClassCount]; + + byte[] body = CharacterCreate.BuildRequestBody("cd", request, skills); + + int pos = 0; + Assert.Equal(CharacterCreate.Opcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; + + // String16L("cd") = u16(2) + 2 bytes, already 4-byte aligned. + Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2; + Assert.Equal("cd", Encoding.ASCII.GetString(body, pos, 2)); pos += 2; + + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // constant + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // heritage + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // gender + + // 14 appearance strip/color u32 fields, all zero (default). + pos += 14 * 4; + + // 6 f64 shades, all zero (default). + pos += 6 * 8; + + pos += 4; // template + pos += 6 * 4; // attributes + pos += 4; // slot + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // classId + + Assert.Equal( + (uint)CharacterCreate.SkillAdvancementClassCount, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); + pos += 4; + pos += CharacterCreate.SkillAdvancementClassCount * 4; + + Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos))); pos += 2; + Assert.Equal("ab", Encoding.ASCII.GetString(body, pos, 2)); pos += 2; + + pos += 4; // startArea + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isAdmin + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; // isEnvoy + + // Checksum: heritage(1) + gender(0) + 3 strips(0) + hairColor(0) + + // eyeColor(0) + hairStyle(0) + headgearStyle(0) + shirtStyle(0) + + // trousersStyle(0) + footwearStyle(0) + template(0) + 6 attrs(0) = 1. + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4; + + Assert.Equal(pos, body.Length); + } + + [Fact] + public void ComputeChecksum_ExactRetailAccumulationSet() + { + // CG_Pack@0x005c7213-0x005c74c3: heritage, gender, the three + // appearance strips, hairColor, eyeColor, hairStyle, headgearStyle, + // shirtStyle, trousersStyle, footwearStyle, template, and the six + // attributes — nineteen terms, u32 wraparound addition. + CharacterCreate.Request request = MakeRequest(); + uint expected = unchecked( + request.Heritage + + request.Gender + + request.Appearance.EyesStrip + + request.Appearance.NoseStrip + + request.Appearance.MouthStrip + + request.Appearance.HairColor + + request.Appearance.EyeColor + + request.Appearance.HairStyle + + request.Appearance.HeadgearStyle + + request.Appearance.ShirtStyle + + request.Appearance.TrousersStyle + + request.Appearance.FootwearStyle + + request.Template + + request.Attributes.Strength + + request.Attributes.Endurance + + request.Attributes.Coordination + + request.Attributes.Quickness + + request.Attributes.Focus + + request.Attributes.Self); + + Assert.Equal(expected, CharacterCreate.ComputeChecksum(request)); + // Concretely: 1+0+2+3+4+5+6+7+8+10+12+14+16+17+18+19+20+21+22 = 205. + Assert.Equal(205u, expected); + } + + [Fact] + public void ComputeChecksum_ExcludesColorFieldsShadesSlotAndClassId() + { + // These fields sit adjacent to summed fields on the wire but the + // decompiled CG_Pack accumulation (0x005c7213-0x005c74c3) never + // touches them — mutating only these must not move the checksum. + CharacterCreate.Request baseline = MakeRequest(); + uint baselineChecksum = CharacterCreate.ComputeChecksum(baseline); + + CharacterCreate.Request mutated = baseline with + { + Appearance = baseline.Appearance with + { + HeadgearColor = baseline.Appearance.HeadgearColor + 1000u, + ShirtColor = baseline.Appearance.ShirtColor + 1000u, + TrousersColor = baseline.Appearance.TrousersColor + 1000u, + FootwearColor = baseline.Appearance.FootwearColor + 1000u, + SkinShade = baseline.Appearance.SkinShade + 5.0, + HairShade = baseline.Appearance.HairShade + 5.0, + }, + Slot = baseline.Slot + 7u, + ClassId = baseline.ClassId + 7u, + }; + + Assert.Equal(baselineChecksum, CharacterCreate.ComputeChecksum(mutated)); + } + + [Fact] + public void BuildRequestBody_SkillCountOtherThan55_Throws() + { + CharacterCreate.Request request = MakeRequest(); + + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills().AsSpan(0, 54))); + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, new uint[56])); + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, ReadOnlySpan.Empty)); + } + + [Fact] + public void BuildRequestBody_NullAccountName_Throws() + { + CharacterCreate.Request request = MakeRequest(); + Assert.Throws(() => + CharacterCreate.BuildRequestBody(null!, request, MakeSkills())); + } + + [Fact] + public void BuildRequestBody_NullCharacterName_Throws() + { + CharacterCreate.Request request = MakeRequest() with { Name = null! }; + Assert.Throws(() => + CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills())); + } + + [Fact] + public void BuildRequestBody_AdminAndEnvoyFlags_EncodeAsOneOrZero() + { + CharacterCreate.Request request = MakeRequest() with { IsAdmin = true, IsEnvoy = true }; + byte[] body = CharacterCreate.BuildRequestBody("testaccount", request, MakeSkills()); + + // isAdmin and isEnvoy are the two u32s immediately before the + // trailing checksum. + uint isEnvoy = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 8)); + uint isAdmin = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 12)); + Assert.Equal(1u, isAdmin); + Assert.Equal(1u, isEnvoy); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs new file mode 100644 index 00000000..0fb4f7ef --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs @@ -0,0 +1,287 @@ +using System.Net; +using System.Reflection; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using AcDream.Core.Net.Tests.Messages; + +namespace AcDream.Core.Net.Tests; + +/// +/// Campaign CC CC2: the awaiting-request latch that disambiguates the two +/// message families sharing opcode 0xF643 (see +/// 's doc comment) — create-then- +/// response routes to the create event, a plain restore is unaffected, an +/// unexpected/uncorrelated response is dropped rather than misattributed, +/// and teardown clears the latch. See +/// for the general +/// character-management wire-order coverage this file complements. +/// +public sealed class WorldSessionCharacterCreationTests +{ + private sealed class NullTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + public int Receive( + Span destination, + TimeSpan timeout, + out IPEndPoint? from) + { + from = null; + return -1; + } + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + ValueTask.FromCanceled(cancellationToken); + public void Dispose() { } + } + + private static WorldSession CreateSession() => + new( + new IPEndPoint(IPAddress.Loopback, 9000), + new NullTransport()); + + private static CharacterCreate.Request MakeCreateRequest() => new( + Heritage: 1u, + Gender: 0u, + Appearance: default, + Template: 0u, + Attributes: default, + Slot: 0u, + ClassId: 1u, + Name: "NewChar", + StartArea: 0u, + IsAdmin: false, + IsEnvoy: false); + + private static byte[] BuildVerificationResponseBody(uint code, uint guid, string name) => + code == (uint)CharGenVerificationResponse.Code.Ok + ? AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(code) + .WriteGuid(guid) + .WriteString16L(name) + .Write(0u) + .ToArray() + : AceWireWriter.GameMessage(CharGenVerificationResponse.ResponseOpcode) + .Write(code) + .ToArray(); + + private static byte[] BuildPacket(params byte[][] messages) + { + int length = messages.Sum(message => + MessageFragmentHeader.Size + message.Length); + var fragments = new byte[length]; + int position = 0; + uint sequence = 1u; + foreach (byte[] message in messages) + { + position += GameMessageFragment.WriteSingleFragment( + fragments.AsSpan(position), + sequence++, + GameMessageGroup.UIQueue, + message); + } + return PacketCodec.Encode( + new PacketHeader + { + Sequence = 1u, + Flags = PacketHeaderFlags.BlobFragments, + }, + fragments, + outboundIsaac: null); + } + + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) + { + MethodInfo method = typeof(WorldSession).GetMethod( + "ProcessDatagram", + BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(session, [new ReadOnlyMemory(datagram), null, true]); + } + + private static PendingLatch ReadPendingLatch(WorldSession session) + { + FieldInfo field = typeof(WorldSession).GetField( + "_pendingCharGenVerification", + BindingFlags.NonPublic | BindingFlags.Instance)!; + return (PendingLatch)field.GetValue(session)!; + } + + // Mirrors WorldSession's private PendingCharGenVerificationRequest enum + // by name/ordinal — read via reflection above so the test doesn't need + // InternalsVisibleTo for a single private enum. + private enum PendingLatch { None, Restore, Create } + + [Fact] + public void SendCharacterCreation_ThenOkResponse_RoutesToCreateEventNotRestore() + { + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + + var createEvents = new List(); + var restoreEvents = new List(); + session.CharacterCreateResponseReceived += createEvents.Add; + session.CharacterRestoreReceived += restoreEvents.Add; + + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000010u, + "NewChar")); + InvokeProcessDatagram(session, packet); + + CharGenVerificationResponse.Parsed created = Assert.Single(createEvents); + Assert.True(created.IsOk); + Assert.Equal(0x50000010u, created.Guid); + Assert.Equal("NewChar", created.Name); + Assert.Empty(restoreEvents); + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + + [Fact] + public void SendCharacterCreation_ThenFailureResponse_RoutesToCreateEventWithNullIdentity() + { + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + + CharGenVerificationResponse.Parsed? created = null; + session.CharacterCreateResponseReceived += parsed => created = parsed; + + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.NameInUse, + guid: 0u, + name: string.Empty)); + InvokeProcessDatagram(session, packet); + + Assert.NotNull(created); + Assert.Equal(CharGenVerificationResponse.Code.NameInUse, created!.Value.AsCode); + Assert.False(created.Value.IsOk); + Assert.Null(created.Value.Guid); + } + + [Fact] + public void SendRestoreCharacter_ThenResponse_StillRoutesToRestoreEvent() + { + // Regression guard: the correlation latch must not break the + // pre-existing restore-only flow that predates Campaign CC. + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendRestoreCharacter(0x50000001u); + + var restoreEvents = new List(); + var createEvents = new List(); + session.CharacterRestoreReceived += restoreEvents.Add; + session.CharacterCreateResponseReceived += createEvents.Add; + + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000001u, + "Restored")); + InvokeProcessDatagram(session, packet); + + CharacterRestore.Parsed restored = Assert.Single(restoreEvents); + Assert.Equal(0x50000001u, restored.Guid); + Assert.Equal("Restored", restored.Name); + Assert.Empty(createEvents); + } + + [Fact] + public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed() + { + using WorldSession session = CreateSession(); + + var restoreEvents = new List(); + var createEvents = new List(); + session.CharacterRestoreReceived += restoreEvents.Add; + session.CharacterCreateResponseReceived += createEvents.Add; + + // No SendRestoreCharacter / SendCharacterCreation call precedes this + // — the latch is None. + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000099u, + "Stray")); + InvokeProcessDatagram(session, packet); + + Assert.Empty(restoreEvents); + Assert.Empty(createEvents); + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + + [Fact] + public void SecondResponse_AfterFirstAlreadyConsumed_IsDroppedNotMisattributed() + { + // A create request is satisfied; a SECOND, uncorrelated 0xF643 + // arriving afterward (e.g. a stray/replayed packet) must not be + // misread as a reply to anything. + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + + var createEvents = new List(); + session.CharacterCreateResponseReceived += createEvents.Add; + + byte[] first = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, 0x50000010u, "NewChar")); + InvokeProcessDatagram(session, first); + Assert.Single(createEvents); + + byte[] second = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, 0x50000011u, "Stray")); + InvokeProcessDatagram(session, second); + + // Still exactly one — the second reply was dropped, not appended. + Assert.Single(createEvents); + } + + [Fact] + public void Dispose_ClearsTheOutstandingLatch() + { + WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + session.SendRestoreCharacter(0x50000001u); + Assert.Equal(PendingLatch.Restore, ReadPendingLatch(session)); + + session.Dispose(); + + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + + [Fact] + public void BuildRequestBody_InvalidSkillCount_DoesNotArmTheLatch() + { + // The latch is armed AFTER the body is built (SendCharacterCreation + // builds first), so a builder-level throw (wrong skill count) must + // leave no outstanding request behind — nothing was actually sent. + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + Assert.Throws(() => + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[10])); + + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs index 74cba578..3d3b6dc6 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterSelectionTests.cs @@ -61,6 +61,13 @@ public sealed class WorldSessionCharacterSelectionTests public void UiQueueReplies_DispatchInWireOrderAndRosterRefreshReplacesCharacters() { using var session = CreateSession(); + // Campaign CC CC2: a restore response only dispatches when the + // session actually has an outstanding restore request armed — see + // WorldSessionCharacterCreationTests for the correlation-specific + // coverage (create routing, no-outstanding drop, teardown clears). + session.GameMessageCapture = (_, _) => { }; + session.SendRestoreCharacter(0x50000001u); + var events = new List(); session.CharacterListReceived += roster => events.Add($"roster:{roster.Characters[0].SecondsGreyedOut}"); diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index 2055ae92..10904453 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -77,6 +77,35 @@ public sealed class StatusEventParserTests Assert.Equal("boom", failed.Error); } + [Fact] + public void ParsesCharacterCreatedAndCreationFailed() + { + var created = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}""")); + Assert.Equal(1342177296u, created.Guid); + Assert.Equal("NewChar", created.Name); + + var failed = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"name":"NameInUse"}""")); + Assert.Equal(3u, failed.Code); + Assert.Equal("NameInUse", failed.Name); + } + + [Theory] + [InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"name\":\"NewChar\"}")] + [InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"guid\":1342177296}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"name\":\"NameInUse\"}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3}")] + public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line) + { + var malformed = Assert.IsType(StatusEventParser.Parse(line)); + + Assert.Equal("s1", malformed.SessionId); + Assert.False(string.IsNullOrWhiteSpace(malformed.Error)); + } + [Fact] public void ParsesLoginCommandFailed() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs index d2ad011c..bf30d037 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -183,6 +183,33 @@ public sealed class StatusFileTailerTests : IDisposable Assert.Empty(events); } + /// + /// Campaign CC CC2: the two creation-flow events round-trip through the + /// actual file-tailing pipeline (not just + /// in isolation) — matching the exact camelCase shape + /// AcDream.Runtime.Session.SessionStatusWriter writes. + /// + [Fact] + public void TailsCharacterCreatedAndCreationFailedEvents() + { + AppendShared( + """{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}""" + + "\n" + + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"name":"NameInUse"}""" + + "\n"); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(2, events.Count); + var created = Assert.IsType(events[0]); + Assert.Equal(1342177296u, created.Guid); + Assert.Equal("NewChar", created.Name); + var failed = Assert.IsType(events[1]); + Assert.Equal(3u, failed.Code); + Assert.Equal("NameInUse", failed.Name); + } + [Fact] public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced() { diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index 95e37ff8..d9b0892a 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -109,6 +109,8 @@ public sealed class SessionStatusWriterTests writer.PluginLoaded("s1", "acdream.good"); writer.PluginFailed("s1", "acdream.bad", "failed"); writer.LoginCommandFailed("s1", 0, "", "unknown command"); + writer.CharacterCreated("s1", 0x50000001u, "NewChar"); + writer.CreationFailed("s1", 3u, "NameInUse"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); @@ -116,6 +118,41 @@ public sealed class SessionStatusWriterTests Assert.False(File.Exists(file.Path)); } + /// + /// Campaign CC CC2: pins the exact shape of the two new creation-flow + /// status events, added to the LA1 vocabulary alongside + /// CharacterCreate (opcode 0xF656) — see + /// docs/plans/2026-08-14-launcher-campaign.md §LA1's amended + /// status-vocabulary text. + /// + [Fact] + public void CharacterCreatedAndCreationFailed_WriteThePinnedShape() + { + using TemporaryFile file = TemporaryFile.Create(); + var writer = new SessionStatusWriter(file.Path); + + writer.CharacterCreated("s1", 0x50000010u, "NewChar"); + writer.CreationFailed("s1", 3u, "NameInUse"); + + string[] lines = File.ReadAllLines(file.Path); + Assert.Equal(2, lines.Length); + + JsonElement created = Parse(lines[0]); + Assert.Equal(1, created.GetProperty("v").GetInt32()); + Assert.Equal("characterCreated", created.GetProperty("e").GetString()); + Assert.Equal("s1", created.GetProperty("sessionId").GetString()); + Assert.Equal(0x50000010u, created.GetProperty("guid").GetUInt32()); + Assert.Equal("NewChar", created.GetProperty("name").GetString()); + AssertExactProperties(lines[0], "v", "e", "t", "sessionId", "guid", "name"); + + JsonElement failed = Parse(lines[1]); + Assert.Equal("creationFailed", failed.GetProperty("e").GetString()); + Assert.Equal("s1", failed.GetProperty("sessionId").GetString()); + Assert.Equal(3u, failed.GetProperty("code").GetUInt32()); + Assert.Equal("NameInUse", failed.GetProperty("name").GetString()); + AssertExactProperties(lines[1], "v", "e", "t", "sessionId", "code", "name"); + } + [Fact] public void BlankPathIsTreatedAsAbsent() { From 0445004164ddb2710e7dfa1cce3f7e1aeb80d10b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:53:50 +0200 Subject: [PATCH 078/138] =?UTF-8?q?feat(content):=20Campaign=20CC=20CC1=20?= =?UTF-8?q?=E2=80=94=20chargen=20table=20reader=20and=20typed=20options=20?= =?UTF-8?q?model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the CC1 data layer for Campaign CC (retail character creation): a reader for portal.dat's CharGen table (0x0E000002) plus a presentation-free, Chorizite-free typed options model, and the pure attribute/skill credit math the later CC3 Runtime owner needs. Retail oracle (docs/research/named-retail/acclient_2013_pseudo_c.txt): - ACCharGenData::Serialize @ 0x005C36D0 (table shape: StartingAreas + HeritageGroups) - HeritageGroup_CG::Serialize @ 0x005C2100 - Sex_CG::Serialize @ 0x005C1600 - Template_CG::Serialize @ 0x005C0450 - CharGenState::SetHeritageGroup @ 0x005C67A0 and the six attribute-slider setters (~0x005C46CE..0x005C494E): remainingAtrbCredits = totalAtrbCredits - (str+end+coord+quick+focus+self) — a heritage's AttributeCredits is the budget the six RAW attribute values must fit, not points above the floor. - CharGenState::Reset @ 0x005C68A0: atrbMin=10, atrbMax=100. - gmCharGenMainUI::DoFinish @ 0x004E9170: Finish refuses only when remainingAtrbCredits > 0 (attributes only — skill credits are never gated to zero, confirmed by reading the function body). - CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0: exactly one of NormalCost/PrimaryCost is charged per Trained/Specialized skill. - gmCGAppearancePage::Update @ 0x0047E8F0: the mHeritageGroup==0xc/0xd (Olthoi/OlthoiAcid) camera-offset branch CC6 will need. Cross-checked against ACE's ACE.DatLoader.FileTypes.CharGen and ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG/SkillCG loaders (same field order, different byte format) and ACE.Entity.Enum.HeritageGroup / SkillAdvancementClass for the two small stable enums the model exposes. src/AcDream.Core/CharGen/: ChargenOptions (root: StarterAreas + HeritagesById), ChargenHeritageOptions, ChargenGenderOptions (BaseObjDesc + every appearance-option list: hair styles/colors, eye colors, eye/nose/ mouth strips, headgear/shirt/pants/footwear, clothing colors), ChargenTemplate, ChargenObjDesc (palette/subpalette/texture/anim-part-swap shape, mirrors PaletteOverride's presentation-free pattern), and the pure math: ChargenAttributeMath (RemainingCredits/IsFullySpent/range checks) and ChargenSkillCreditMath (retail's Trained-xor-Specialized cost sum) plus ChargenSkillAdvancementSet, a structurally-fixed 55-slot type (reserved slot 0 + SkillId 1..54) so CC2's future wire builder cannot send anything but exactly 55 entries. src/AcDream.Content/CharGen/ChargenTableReader.cs projects the Chorizite DBObj graph into the Core model (MagicCatalog.Load's shape) — no Chorizite type crosses into ChargenOptions. Tests: hand-built-fixture unit tests for the pure math (Core.Tests) and the Content projector (Content.Tests), plus six installed-DAT gate tests (ContentConformanceDats pattern) against the real portal.dat: 13 heritage groups (11 standard + 2 Olthoi), the four named heritages with retail display names incl. "Gharu'ndim", every heritage has a gender with non-empty appearance option lists, every template's attributes stay in 10..100 and never exceed its heritage's budget (discovered live: NOT every template fully spends it — each human heritage's "Adventurer" template sits at the floor as retail's real-DAT-backed "Custom" starting point), start-area indices resolve into the shared list, and skill costs key to valid 1..54 wire ids. Co-Authored-By: Claude Fable 5 --- .../CharGen/ChargenTableReader.cs | 215 ++++++++++ .../CharGen/ChargenAppearanceOptions.cs | 41 ++ .../CharGen/ChargenAttributeMath.cs | 56 +++ .../CharGen/ChargenAttributeValues.cs | 22 + .../CharGen/ChargenGenderOptions.cs | 54 +++ .../CharGen/ChargenHeritageGroup.cs | 31 ++ .../CharGen/ChargenHeritageOptions.cs | 32 ++ src/AcDream.Core/CharGen/ChargenObjDesc.cs | 41 ++ src/AcDream.Core/CharGen/ChargenOptions.cs | 34 ++ .../CharGen/ChargenSkillAdvancement.cs | 80 ++++ .../CharGen/ChargenSkillCreditMath.cs | 62 +++ .../CharGen/ChargenStarterArea.cs | 23 + src/AcDream.Core/CharGen/ChargenTemplate.cs | 19 + .../ChargenTableReaderInstalledDatTests.cs | 214 ++++++++++ .../CharGen/ChargenTableReaderTests.cs | 403 ++++++++++++++++++ .../CharGen/ChargenAttributeMathTests.cs | 86 ++++ .../CharGen/ChargenOptionsTests.cs | 77 ++++ .../ChargenSkillAdvancementSetTests.cs | 80 ++++ .../CharGen/ChargenSkillCreditMathTests.cs | 86 ++++ 19 files changed, 1656 insertions(+) create mode 100644 src/AcDream.Content/CharGen/ChargenTableReader.cs create mode 100644 src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs create mode 100644 src/AcDream.Core/CharGen/ChargenAttributeMath.cs create mode 100644 src/AcDream.Core/CharGen/ChargenAttributeValues.cs create mode 100644 src/AcDream.Core/CharGen/ChargenGenderOptions.cs create mode 100644 src/AcDream.Core/CharGen/ChargenHeritageGroup.cs create mode 100644 src/AcDream.Core/CharGen/ChargenHeritageOptions.cs create mode 100644 src/AcDream.Core/CharGen/ChargenObjDesc.cs create mode 100644 src/AcDream.Core/CharGen/ChargenOptions.cs create mode 100644 src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs create mode 100644 src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs create mode 100644 src/AcDream.Core/CharGen/ChargenStarterArea.cs create mode 100644 src/AcDream.Core/CharGen/ChargenTemplate.cs create mode 100644 tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs create mode 100644 tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenSkillAdvancementSetTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs diff --git a/src/AcDream.Content/CharGen/ChargenTableReader.cs b/src/AcDream.Content/CharGen/ChargenTableReader.cs new file mode 100644 index 00000000..4b30a776 --- /dev/null +++ b/src/AcDream.Content/CharGen/ChargenTableReader.cs @@ -0,0 +1,215 @@ +using AcDream.Core.CharGen; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using CoreChargenObjDesc = AcDream.Core.CharGen.ChargenObjDesc; +using DatCharGen = DatReaderWriter.DBObjs.CharGen; +using DatObjDesc = DatReaderWriter.Types.ObjDesc; + +namespace AcDream.Content.CharGen; + +/// +/// Projects portal.dat's CharGen table (id , +/// retail ACCharGenData::Serialize @ 0x005C36D0) into acdream's +/// presentation-free tree. +/// Mirrors MagicCatalog.Load's shape: one static entry point over +/// , no Chorizite types cross into the +/// returned model. Cross-checked against ACE's +/// ACE.DatLoader.FileTypes.CharGen + +/// ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG loaders, +/// which unpack the identical field order from the same DAT bytes. +/// +public static class ChargenTableReader +{ + /// Retail's CharGen DAT file id (ACE: + /// ACE.DatLoader.FileTypes.CharGen.FILE_ID). + public const uint ChargenTableDid = 0x0E000002u; + + /// + /// Loads and projects the installed CharGen table. Returns + /// if the table is missing from the + /// supplied dat source (mirrors MagicCatalog's tolerance for a + /// missing optional table — callers that require the table present + /// should check HeritagesById.Count themselves). + /// + public static ChargenOptions Load(IDatReaderWriter dats) + { + ArgumentNullException.ThrowIfNull(dats); + + DatCharGen? table = dats.Get(ChargenTableDid); + return table is null ? ChargenOptions.Empty : Project(table); + } + + /// Pure projection from an already-loaded DAT record — split out + /// from so tests can exercise it against + /// hand-built fixtures without a live DAT. + public static ChargenOptions Project(DatCharGen table) + { + ArgumentNullException.ThrowIfNull(table); + + var starterAreas = new List(table.StartingAreas.Count); + for (int i = 0; i < table.StartingAreas.Count; i++) + starterAreas.Add(ProjectStarterArea(i, table.StartingAreas[i])); + + var heritagesById = new Dictionary(table.HeritageGroups.Count); + foreach (KeyValuePair pair in table.HeritageGroups) + heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value); + + return new ChargenOptions(starterAreas, heritagesById); + } + + private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area) + { + var locations = new List(area.Locations.Count); + foreach (Position position in area.Locations) + { + locations.Add(new ChargenPosition( + position.CellId, + position.Frame.Origin, + position.Frame.Orientation)); + } + return new ChargenStarterArea(index, area.Name.Value, locations); + } + + private static ChargenHeritageOptions ProjectHeritage(uint heritageId, HeritageGroupCG cg) + { + var skillCosts = new Dictionary(cg.Skills.Count); + foreach (SkillCG skill in cg.Skills) + { + uint skillId = (uint)skill.Id; + skillCosts[skillId] = new ChargenSkillCost(skillId, skill.NormalCost, skill.PrimaryCost); + } + + var templates = new List(cg.Templates.Count); + foreach (TemplateCG template in cg.Templates) + templates.Add(ProjectTemplate(template)); + + var gendersByKey = new Dictionary(cg.Genders.Count); + foreach (KeyValuePair pair in cg.Genders) + gendersByKey[pair.Key] = ProjectGender(pair.Key, pair.Value); + + return new ChargenHeritageOptions( + heritageId, + cg.Name.Value, + cg.IconId.DataId, + cg.SetupId.DataId, + cg.EnvironmentSetupId.DataId, + cg.AttributeCredits, + cg.SkillCredits, + new List(cg.PrimaryStartAreas), + new List(cg.SecondaryStartAreas), + skillCosts, + templates, + gendersByKey); + } + + private static ChargenTemplate ProjectTemplate(TemplateCG template) + { + var normalSkills = new List(template.NormalSkills.Count); + foreach (var skillId in template.NormalSkills) + normalSkills.Add((uint)skillId); + + var primarySkills = new List(template.PrimarySkills.Count); + foreach (var skillId in template.PrimarySkills) + primarySkills.Add((uint)skillId); + + return new ChargenTemplate( + template.Name.Value, + template.IconId.DataId, + template.Title, + new ChargenAttributeValues( + template.Strength, + template.Endurance, + template.Coordination, + template.Quickness, + template.Focus, + template.Self), + normalSkills, + primarySkills); + } + + private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex) + { + var hairStyles = new List(sex.HairStyles.Count); + foreach (HairStyleCG hair in sex.HairStyles) + { + hairStyles.Add(new ChargenHairStyle( + hair.IconId.DataId, + hair.Bald, + hair.AlternateSetup, + ProjectObjDesc(hair.ObjDesc))); + } + + var eyeStrips = new List(sex.EyeStrips.Count); + foreach (EyeStripCG eye in sex.EyeStrips) + { + eyeStrips.Add(new ChargenEyeStrip( + eye.IconId.DataId, + eye.BaldIconId, + ProjectObjDesc(eye.ObjDesc), + ProjectObjDesc(eye.BaldObjDesc))); + } + + var noseStrips = new List(sex.NoseStrips.Count); + foreach (FaceStripCG strip in sex.NoseStrips) + noseStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc))); + + var mouthStrips = new List(sex.MouthStrips.Count); + foreach (FaceStripCG strip in sex.MouthStrips) + mouthStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc))); + + return new ChargenGenderOptions( + genderKey, + sex.Name.Value, + sex.Scale, + sex.SetupId.DataId, + sex.SoundTable.DataId, + sex.IconId.DataId, + sex.BasePalette.DataId, + sex.SkinPalSet.DataId, + sex.PhysicsTable.DataId, + sex.MotionTable.DataId, + sex.CombatTable.DataId, + ProjectObjDesc(sex.BaseObjDesc), + new List(sex.HairColors), + hairStyles, + new List(sex.EyeColors), + eyeStrips, + noseStrips, + mouthStrips, + ProjectGearList(sex.Headgears), + ProjectGearList(sex.Shirts), + ProjectGearList(sex.Pants), + ProjectGearList(sex.Footwear), + new List(sex.ClothingColors)); + } + + private static List ProjectGearList(List gearList) + { + var result = new List(gearList.Count); + foreach (GearCG gear in gearList) + result.Add(new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault)); + return result; + } + + private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc) + { + var subPalettes = new List(objDesc.SubPalettes.Count); + foreach (SubPalette sub in objDesc.SubPalettes) + subPalettes.Add(new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors)); + + var textureChanges = new List(objDesc.TextureChanges.Count); + foreach (TextureMapChange change in objDesc.TextureChanges) + { + textureChanges.Add(new ChargenTextureChange( + change.PartIndex, + change.OldTexture.DataId, + change.NewTexture.DataId)); + } + + var animPartChanges = new List(objDesc.AnimPartChanges.Count); + foreach (AnimationPartChange change in objDesc.AnimPartChanges) + animPartChanges.Add(new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId)); + + return new CoreChargenObjDesc(objDesc.PaletteId.DataId, subPalettes, textureChanges, animPartChanges); + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs b/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs new file mode 100644 index 00000000..7894374e --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs @@ -0,0 +1,41 @@ +namespace AcDream.Core.CharGen; + +/// +/// One hair-style option in a +/// list. Retail schema: HairStyle_CG (nested inside +/// Sex_CG::Serialize @ 0x005C1600). Bald and +/// AlternateSetup handle the Gear Knight / Olthoi bald-head special +/// case ACE's SexCG.GetHeadObject comment documents. +/// +public sealed record ChargenHairStyle( + uint IconId, + bool Bald, + uint AlternateSetup, + ChargenObjDesc ObjDesc); + +/// +/// One eye-strip option. Retail carries a SEPARATE bald variant +/// (BaldIconId / BaldObjDesc) because a bald hairstyle +/// selection changes which eye texture applies — see ACE's +/// SexCG.GetEyeTexture(strip, isBald). +/// +public sealed record ChargenEyeStrip( + uint IconId, + uint BaldIconId, + ChargenObjDesc ObjDesc, + ChargenObjDesc BaldObjDesc); + +/// One nose- or mouth-strip option (retail FaceStrip_CG). +public sealed record ChargenFaceStrip(uint IconId, ChargenObjDesc ObjDesc); + +/// +/// One clothing-slot option (headgear/shirt/pants/footwear). Retail schema: +/// Gear_CG. ClothingTableId resolves through +/// ClothingTable::BuildObjDesc; WeenieDefaultId is the weenie +/// class the character actually receives in inventory on creation (ACE's +/// SexCG.GetHeadgearWeenie family). +/// +public sealed record ChargenGearOption( + string Name, + uint ClothingTableId, + uint WeenieDefaultId); diff --git a/src/AcDream.Core/CharGen/ChargenAttributeMath.cs b/src/AcDream.Core/CharGen/ChargenAttributeMath.cs new file mode 100644 index 00000000..2b7b6807 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAttributeMath.cs @@ -0,0 +1,56 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's attribute-credit budget math +/// (CharGenState::SetHeritageGroup @ 0x005C67A0 and the six +/// attribute-slider setters around 0x005C46CE..0x005C494E, all of +/// the shape remainingAtrbCredits = totalAtrbCredits - (str + end + +/// coord + quick + focus + self)): a heritage's AttributeCredits +/// is the total budget the SIX RAW attribute values (each already including +/// its 10-point floor) must sum to exactly — not a budget of points spent +/// above the floor. Retail's Finish gate +/// (gmCharGenMainUI::DoFinish @ 0x004E9170, line +/// if (arg2 != 0 && eax->remainingAtrbCredits > 0)) aborts +/// creation with a warning dialog whenever credits remain unspent — retail +/// forces a full spend. ACE's server does not re-validate this; acdream +/// ports the CLIENT gate (see the campaign plan's Finish section). +/// +public static class ChargenAttributeMath +{ + /// Retail's CharGenState::Reset @ 0x005C68A0 + /// this->atrbMin = 0xa — every attribute's floor. + public const int AttributeMin = 10; + + /// Retail's CharGenState::Reset + /// this->atrbMax = 0x64 — every attribute's ceiling. + public const int AttributeMax = 100; + + /// attributeCreditBudget - values.Total. Zero means the + /// budget is exactly spent; positive means credits remain (Finish must + /// refuse); this port never expects negative (retail's own slider + /// clamping through ConstrainAllByHeritage prevents overspend, + /// but callers building a candidate outside that UI path should treat a + /// negative result as an invalid state, not silently accept it). + public static int RemainingCredits(uint attributeCreditBudget, ChargenAttributeValues values) => + checked((int)attributeCreditBudget) - values.Total; + + /// Retail's Finish gate: creation may proceed only when this is + /// true. + public static bool IsFullySpent(uint attributeCreditBudget, ChargenAttributeValues values) => + RemainingCredits(attributeCreditBudget, values) == 0; + + /// True when a single attribute value falls within + /// .. inclusive. + public static bool IsWithinRange(int value) => value >= AttributeMin && value <= AttributeMax; + + /// True when every one of the six attributes falls within + /// range individually (does not check the credit total — see + /// for that). + public static bool AreAllWithinRange(ChargenAttributeValues values) => + IsWithinRange(values.Strength) + && IsWithinRange(values.Endurance) + && IsWithinRange(values.Coordination) + && IsWithinRange(values.Quickness) + && IsWithinRange(values.Focus) + && IsWithinRange(values.Self); +} diff --git a/src/AcDream.Core/CharGen/ChargenAttributeValues.cs b/src/AcDream.Core/CharGen/ChargenAttributeValues.cs new file mode 100644 index 00000000..21a35a47 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAttributeValues.cs @@ -0,0 +1,22 @@ +namespace AcDream.Core.CharGen; + +/// +/// The six primary attributes in retail's chargen wire/serialization order +/// (Strength, Endurance, Coordination, Quickness, Focus, Self) — matches +/// both Template_CG::Serialize @ 0x005C0450 and the 0xF656 +/// ACCharGenData::CG_Pack @ 0x005C7200 attribute block the plan +/// documents. Used both for a preset 's fixed +/// spread and, by CC3's Runtime owner, as the candidate values a "Custom" +/// profession is actively assigning via the six attribute sliders +/// (0x100003e6..eb). +/// +public readonly record struct ChargenAttributeValues( + int Strength, + int Endurance, + int Coordination, + int Quickness, + int Focus, + int Self) +{ + public int Total => Strength + Endurance + Coordination + Quickness + Focus + Self; +} diff --git a/src/AcDream.Core/CharGen/ChargenGenderOptions.cs b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs new file mode 100644 index 00000000..0ee4b2e0 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs @@ -0,0 +1,54 @@ +namespace AcDream.Core.CharGen; + +/// +/// Per-gender chargen options for one heritage: base body model plus every +/// appearance option list the Appearance page's spin/color controls +/// (0x100003af..b8, 0x1000030e..0x10000321) enumerate. Retail schema: +/// Sex_CG::Serialize @ 0x005C1600 (ACE's SexCG.Unpack mirrors +/// the same field order). GenderKey is the raw key from +/// HeritageGroupCG.Genders (retail/ACE both key this as a small int — +/// carried through unmapped rather than assumed 0=male/1=female, confirmed +/// live by CC1's installed-DAT tests). +/// +public sealed record ChargenGenderOptions( + int GenderKey, + string Name, + uint Scale, + uint SetupId, + uint SoundTableId, + uint IconId, + uint BasePaletteId, + uint SkinPalSetId, + uint PhysicsTableId, + uint MotionTableId, + uint CombatTableId, + ChargenObjDesc BaseObjDesc, + IReadOnlyList HairColors, + IReadOnlyList HairStyles, + IReadOnlyList EyeColors, + IReadOnlyList EyeStrips, + IReadOnlyList NoseStrips, + IReadOnlyList MouthStrips, + IReadOnlyList Headgears, + IReadOnlyList Shirts, + IReadOnlyList Pants, + IReadOnlyList Footwear, + IReadOnlyList ClothingColors) +{ + /// + /// Every appearance option list is non-empty for a playable gender — + /// CC1's installed-DAT gate asserts this holds for at least one gender + /// per heritage. A gender missing an option list can still be a valid + /// data shape (e.g. a bald-only heritage's hair styles), so callers + /// building UI should still defend against empty lists individually. + /// + public bool HasAnyAppearanceOptions => + HairStyles.Count > 0 + || EyeStrips.Count > 0 + || NoseStrips.Count > 0 + || MouthStrips.Count > 0 + || Headgears.Count > 0 + || Shirts.Count > 0 + || Pants.Count > 0 + || Footwear.Count > 0; +} diff --git a/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs b/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs new file mode 100644 index 00000000..b69cf525 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs @@ -0,0 +1,31 @@ +namespace AcDream.Core.CharGen; + +/// +/// Retail's 11 standard player heritages plus the two Olthoi player-race +/// variants (ACE's loader comment on CharGen.Unpack: "HERITAGE +/// GROUPS -- 11 standard player races and 2 Olthoi"). Numeric values match +/// ACE's ACE.Entity.Enum.HeritageGroup exactly — the same ids the +/// wire uses and the same ids that key +/// . Display names come from the +/// DAT's own HeritageGroupCG.Name string, not this enum — this enum +/// exists only for callers that need to branch on a KNOWN heritage +/// identity (e.g. CC6's Olthoi-vs-human camera offsets, per the campaign +/// plan's 3D-preview recon). +/// +public enum ChargenHeritageGroup : uint +{ + Invalid = 0, + Aluvian = 1, + Gharundim = 2, + Sho = 3, + Viamontian = 4, + Shadowbound = 5, + Gearknight = 6, + Tumerok = 7, + Lugian = 8, + Empyrean = 9, + Penumbraen = 10, + Undead = 11, + Olthoi = 12, + OlthoiAcid = 13, +} diff --git a/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs b/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs new file mode 100644 index 00000000..a97f3d03 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs @@ -0,0 +1,32 @@ +namespace AcDream.Core.CharGen; + +/// +/// Everything the Heritage/Profession/Skills/Appearance/Town pages need for +/// one heritage. Retail schema: HeritageGroup_CG::Serialize @ +/// 0x005C2100 (ACE's HeritageGroupCG.Unpack mirrors the same +/// field order). PrimaryStartAreaIndices / SecondaryStartAreaIndices +/// index into the CharGen table's SHARED ChargenOptions.StarterAreas +/// list, not a per-heritage list of their own. +/// +public sealed record ChargenHeritageOptions( + uint HeritageId, + string Name, + uint IconId, + uint SetupId, + uint EnvironmentSetupId, + uint AttributeCredits, + uint SkillCredits, + IReadOnlyList PrimaryStartAreaIndices, + IReadOnlyList SecondaryStartAreaIndices, + IReadOnlyDictionary SkillCostsBySkillId, + IReadOnlyList Templates, + IReadOnlyDictionary GendersByKey) +{ + /// True for the two Olthoi player-race variants (ids 12/13) — + /// CC6's 3D preview hard-codes a different camera target position for + /// these (gmCGAppearancePage::Update @ 0x0047E8F0, the + /// mHeritageGroup == 0xc || mHeritageGroup == 0xd branch). + public bool IsOlthoi => + HeritageId == (uint)ChargenHeritageGroup.Olthoi + || HeritageId == (uint)ChargenHeritageGroup.OlthoiAcid; +} diff --git a/src/AcDream.Core/CharGen/ChargenObjDesc.cs b/src/AcDream.Core/CharGen/ChargenObjDesc.cs new file mode 100644 index 00000000..11af5931 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenObjDesc.cs @@ -0,0 +1,41 @@ +namespace AcDream.Core.CharGen; + +/// +/// One palette overlay range inside a . Retail +/// applies NumColors * 8 colors from SubPaletteId starting at +/// Offset * 8 in the base palette (Chorizite.ACProtocol.Types.Subpalette +/// docs; the live-session equivalent is +/// ). +/// +public readonly record struct ChargenSubPalette(uint SubPaletteId, byte Offset, byte NumColors); + +/// One texture-map override inside a . +/// PartIndex identifies which GfxObj part's surface list the swap +/// applies to. +public readonly record struct ChargenTextureChange(byte PartIndex, uint OldTextureId, uint NewTextureId); + +/// One animated-part swap override inside a . +public readonly record struct ChargenAnimPartChange(byte PartIndex, uint PartId); + +/// +/// Presentation-free projection of Chorizite's ObjDesc shape (retail's +/// CObjDesc): the palette id plus the overlay/texture/part-swap deltas +/// a live appearance is built from. Used both for a gender's base body +/// () and for every appearance +/// option's own overlay (hair styles, eye/nose/mouth strips) — CC6's +/// index→ObjDesc appearance factory composes these the same way retail's +/// ClothingTable::BuildObjDesc / DoObjDescChangesFromDefault +/// pipeline does. +/// +public sealed record ChargenObjDesc( + uint PaletteId, + IReadOnlyList SubPalettes, + IReadOnlyList TextureChanges, + IReadOnlyList AnimPartChanges) +{ + public static ChargenObjDesc Empty { get; } = new( + 0u, + Array.Empty(), + Array.Empty(), + Array.Empty()); +} diff --git a/src/AcDream.Core/CharGen/ChargenOptions.cs b/src/AcDream.Core/CharGen/ChargenOptions.cs new file mode 100644 index 00000000..e68555a7 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenOptions.cs @@ -0,0 +1,34 @@ +namespace AcDream.Core.CharGen; + +/// +/// Top-level, presentation-free, immutable projection of retail's CharGen +/// DAT table (portal.dat 0x0E000002, ACCharGenData::Serialize @ +/// 0x005C36D0). Production builds create this from the installed DAT +/// through Content's AcDream.Content.CharGen.ChargenTableReader.Load; +/// this type itself has no DAT/Chorizite dependency so it is safe to hand +/// to plugin-facing or test code. Everything a "typed chargen options +/// model" needs — starter areas, heritages, templates, per-gender +/// appearance option lists, skill costs — hangs off this one root. +/// +public sealed record ChargenOptions( + IReadOnlyList StarterAreas, + IReadOnlyDictionary HeritagesById) +{ + public static ChargenOptions Empty { get; } = new( + Array.Empty(), + new Dictionary()); + + public bool TryGetHeritage(uint heritageId, out ChargenHeritageOptions heritage) => + HeritagesById.TryGetValue(heritageId, out heritage!); + + public bool TryGetStarterArea(int index, out ChargenStarterArea area) + { + if (index >= 0 && index < StarterAreas.Count) + { + area = StarterAreas[index]; + return true; + } + area = null!; + return false; + } +} diff --git a/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs new file mode 100644 index 00000000..cba65741 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs @@ -0,0 +1,80 @@ +namespace AcDream.Core.CharGen; + +/// +/// Retail's four skill states. Wire values match ACE's +/// ACE.Entity.Enum.SkillAdvancementClass exactly (0=Inactive, +/// 1=Untrained, 2=Trained, 3=Specialized) — ACE unpacks the 0xF656 +/// CharacterCreateInfo.SkillAdvancementClasses list with this same +/// numbering, and retail's CharGenState::UpdateRemainingSkillCredits @ +/// 0x005C37C0 only charges credits for Trained (2) and Specialized (3). +/// +public enum ChargenSkillAdvancementClass : uint +{ + Inactive = 0, + Untrained = 1, + Trained = 2, + Specialized = 3, +} + +/// +/// One skill's retail training cost for a heritage. Retail schema: +/// SkillCG, entries of HeritageGroupCG.Skills. +/// PrimaryCost is the TOTAL cost to reach Specialized (not an +/// increment on top of NormalCost) — retail's +/// UpdateRemainingSkillCredits adds exactly one of the two per +/// skill, never both. +/// +public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost); + +/// +/// Retail's fixed-size per-character skill-advancement array +/// (CharGenState.skillLevels). ACE's CharacterCreateInfo.Unpack +/// terminates the connection if the wire's numSkills count is not +/// exactly (55): retail's own loop in +/// UpdateRemainingSkillCredits walks indices 1..totalNumSkills +/// (skipping reserved slot 0), and Chorizite's SkillId enum runs +/// 1..54 — 54 real skills plus the reserved slot 0 is exactly 55. This type +/// makes that shape structural: it always holds exactly 55 slots, so a +/// caller building the 0xF656 body (CC2) cannot accidentally send a +/// different count. +/// +public sealed class ChargenSkillAdvancementSet +{ + /// Slot 0 is reserved (unused by retail); slots 1..54 map 1:1 + /// to Chorizite's DatReaderWriter.Enums.SkillId values. + public const int SlotCount = 55; + + private readonly ChargenSkillAdvancementClass[] _slots = new ChargenSkillAdvancementClass[SlotCount]; + + /// Skill state by raw skill id. Ids outside 1..54 read + /// as and cannot be + /// set. + public ChargenSkillAdvancementClass this[uint skillId] + { + get => skillId >= 1 && skillId < SlotCount + ? _slots[skillId] + : ChargenSkillAdvancementClass.Inactive; + set + { + if (skillId < 1 || skillId >= SlotCount) + throw new ArgumentOutOfRangeException( + nameof(skillId), + skillId, + $"Skill id must be in 1..{SlotCount - 1}."); + _slots[skillId] = value; + } + } + + /// + /// Materializes the wire body shape: exactly + /// entries, slot 0 first, matching ACE's + /// CharacterCreateInfo.SkillAdvancementClasses read order. + /// + public IReadOnlyList ToWireClasses() + { + var wire = new uint[SlotCount]; + for (int i = 0; i < SlotCount; i++) + wire[i] = (uint)_slots[i]; + return wire; + } +} diff --git a/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs new file mode 100644 index 00000000..2a8ab4fa --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs @@ -0,0 +1,62 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's skill-credit spend calculation +/// (CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0): walk +/// every skill slot, add NormalCost for Trained or PrimaryCost +/// for Specialized (never both), and subtract the total from the heritage's +/// SkillCredits budget. No DAT/DatReaderWriter dependency — callers +/// (CC3's Runtime owner) pass in the heritage's already-projected +/// lookup. +/// +public static class ChargenSkillCreditMath +{ + /// + /// Total credits spent across every Trained/Specialized skill in + /// . A skill with no cost entry for the + /// active heritage (i.e. the heritage doesn't offer it) is skipped — + /// retail's own UI can never reach that state, so this is defensive + /// rather than a documented retail behavior. + /// + public static int ComputeSpent( + ChargenSkillAdvancementSet advancement, + IReadOnlyDictionary costsBySkillId) + { + ArgumentNullException.ThrowIfNull(advancement); + ArgumentNullException.ThrowIfNull(costsBySkillId); + + int spent = 0; + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + ChargenSkillAdvancementClass cls = advancement[skillId]; + if (cls != ChargenSkillAdvancementClass.Trained + && cls != ChargenSkillAdvancementClass.Specialized) + { + continue; + } + + if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost)) + continue; + + spent += cls == ChargenSkillAdvancementClass.Specialized + ? cost.PrimaryCost + : cost.NormalCost; + } + return spent; + } + + /// + /// totalSkillCredits - ComputeSpent(...) — retail's + /// remainingSkillCredits. Retail's Finish gate (DoFinish @ + /// 0x004E91F2-adjacent) only checks remainingAtrbCredits > 0 + /// for attributes; skill credits are NOT required to hit exactly zero + /// (unspent skill credits are simply lost on creation) — callers should + /// not port an "exact spend" gate for skills the way + /// does for attributes. + /// + public static int RemainingCredits( + uint totalSkillCredits, + ChargenSkillAdvancementSet advancement, + IReadOnlyDictionary costsBySkillId) => + checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId); +} diff --git a/src/AcDream.Core/CharGen/ChargenStarterArea.cs b/src/AcDream.Core/CharGen/ChargenStarterArea.cs new file mode 100644 index 00000000..e4161e73 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenStarterArea.cs @@ -0,0 +1,23 @@ +using System.Numerics; + +namespace AcDream.Core.CharGen; + +/// One spawn point inside a . Retail +/// schema: Position nested inside StartingArea +/// (ACCharGenData::Serialize @ 0x005C36D0). +public readonly record struct ChargenPosition(uint CellId, Vector3 Origin, Quaternion Orientation); + +/// +/// One named starting area (a town/region a heritage may spawn a new +/// character in) with its candidate spawn points. The CharGen table holds +/// ONE shared list of these — +/// and SecondaryStartAreaIndices reference this list by index, they +/// do not carry their own copies. Retail schema: +/// ACCharGenData::Serialize @ 0x005C36D0 (ACE's loader comment names +/// this StarterArea; Chorizite names the DAT type StartingArea +/// — same shape). +/// +public sealed record ChargenStarterArea( + int Index, + string Name, + IReadOnlyList Locations); diff --git a/src/AcDream.Core/CharGen/ChargenTemplate.cs b/src/AcDream.Core/CharGen/ChargenTemplate.cs new file mode 100644 index 00000000..0bdc5ceb --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenTemplate.cs @@ -0,0 +1,19 @@ +namespace AcDream.Core.CharGen; + +/// +/// One profession preset (Bowhunter, Swashbuckler, Lifecaster, Warmage, +/// Wayfarer, Soldier, ...) offered on the Profession page +/// (UpdateProfession @ 0x00478d1c/0x0047a4a4-adjacent per the +/// campaign plan's recon; template buttons 0x100003da..df). "Custom" is NOT +/// one of these — it is retail's own free-attribute-assignment mode +/// selected by button 0x100003d9 and has no +/// entry. Retail schema: Template_CG::Serialize @ 0x005C0450 +/// (ACE's TemplateCG.Unpack mirrors the same field order). +/// +public sealed record ChargenTemplate( + string Name, + uint IconId, + uint TitleStringId, + ChargenAttributeValues Attributes, + IReadOnlyList NormalSkills, + IReadOnlyList PrimarySkills); diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs new file mode 100644 index 00000000..9c8f4f08 --- /dev/null +++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs @@ -0,0 +1,214 @@ +using AcDream.Content.CharGen; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.Content.Tests.CharGen; + +/// +/// Installed-DAT gate for : proves the real +/// CharGen table (portal.dat 0x0E000002) loads through the SAME +/// production uses and lands in a +/// plausible shape. Env-gated like the rest of Content.Tests' installed-DAT +/// suite () — skips +/// cleanly (with a console note) when no DAT directory is configured, +/// matching e.g. PakEquivalenceTests / RetailDatLoaderTests. +/// +public sealed class ChargenTableReaderInstalledDatTests +{ + // ACE ACE.Entity.Enum.HeritageGroup — the four heritages CC1's spec + // calls out by name. + private const uint AluvianId = 1u; + private const uint GharundimId = 2u; + private const uint ShoId = 3u; + private const uint ViamontianId = 4u; + private const uint OlthoiId = 12u; + private const uint OlthoiAcidId = 13u; + + [Fact] + public void InstalledCharGenTable_LoadsAndHasThirteenHeritageGroups() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + + // ACE's loader comment: "11 standard player races and 2 Olthoi". + Assert.Equal(13, options.HeritagesById.Count); + Assert.NotEmpty(options.StarterAreas); + } + + [Fact] + public void InstalledCharGenTable_HasTheFourNamedHeritagesWithRetailDisplayNames() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian)); + Assert.Equal("Aluvian", aluvian.Name); + + Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions gharundim)); + Assert.Equal("Gharu'ndim", gharundim.Name); + + Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions sho)); + Assert.Equal("Sho", sho.Name); + + Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions viamontian)); + Assert.Equal("Viamontian", viamontian.Name); + + Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions olthoi)); + Assert.True(olthoi.IsOlthoi); + Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions olthoiAcid)); + Assert.True(olthoiAcid.IsOlthoi); + } + + [Fact] + public void InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + Assert.NotEmpty(options.HeritagesById); + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + Assert.NotEmpty(heritage.GendersByKey); + Assert.Contains( + heritage.GendersByKey.Values, + gender => gender.HasAnyAppearanceOptions); + } + } + + /// + /// Every template's six attributes fall within retail's 10..100 range, + /// and no template's total exceeds its heritage's AttributeCredits + /// budget. NOT every template fully spends the budget — each of the + /// four human heritages (and Olthoi Acid) ships an "Adventurer" template + /// sitting at the floor (60 of a 330 budget; confirmed against the + /// installed DAT), which is retail's "Custom" starting point delivered + /// as a real TemplateCG entry rather than a special-cased UI-only + /// option. Every OTHER named human template (Bow Hunter, Swashbuckler, + /// Life Caster, War Mage, Wayfarer, Soldier) exactly exhausts its + /// heritage's credits, and Olthoi's single "Ripper" template exactly + /// exhausts its (much smaller, non-customizable) 60-credit budget — so + /// SOME template somewhere fully spends its budget, even though no + /// single heritage is guaranteed to (Olthoi Acid's only template is the + /// unspent "Adventurer" one). + /// + [Fact] + public void InstalledHeritages_EveryTemplateAttributeSpreadFitsTheAttributeBudget() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + int templatesChecked = 0; + bool anyFullySpentAnywhere = false; + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach (ChargenTemplate template in heritage.Templates) + { + templatesChecked++; + Assert.True( + ChargenAttributeMath.AreAllWithinRange(template.Attributes), + $"{heritage.Name}/{template.Name}: an attribute fell outside " + + $"{ChargenAttributeMath.AttributeMin}..{ChargenAttributeMath.AttributeMax} " + + $"({template.Attributes})."); + Assert.True( + template.Attributes.Total <= heritage.AttributeCredits, + $"{heritage.Name}/{template.Name}: attribute spread totals " + + $"{template.Attributes.Total}, exceeding the {heritage.AttributeCredits}-credit budget."); + anyFullySpentAnywhere |= ChargenAttributeMath.IsFullySpent(heritage.AttributeCredits, template.Attributes); + } + } + + Assert.True(templatesChecked > 0, "Expected at least one profession template across all heritages."); + Assert.True( + anyFullySpentAnywhere, + "Expected at least one named preset template to fully spend its heritage's credit budget."); + } + + [Fact] + public void InstalledHeritages_PrimaryAndSecondaryStartAreaIndicesResolveIntoTheSharedList() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + int indicesChecked = 0; + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach (int index in heritage.PrimaryStartAreaIndices.Concat(heritage.SecondaryStartAreaIndices)) + { + indicesChecked++; + Assert.True( + options.TryGetStarterArea(index, out ChargenStarterArea area), + $"{heritage.Name}: start-area index {index} does not resolve into the shared StarterAreas list."); + Assert.False(string.IsNullOrEmpty(area.Name)); + } + } + + Assert.True(indicesChecked > 0, "Expected at least one heritage start-area reference."); + } + + [Fact] + public void InstalledHeritages_SkillCostsResolveToKnownWireSkillIds() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian)); + Assert.NotEmpty(aluvian.SkillCostsBySkillId); + foreach (var pair in aluvian.SkillCostsBySkillId) + { + Assert.Equal(pair.Key, pair.Value.SkillId); + Assert.InRange(pair.Key, 1u, 54u); + Assert.True(pair.Value.NormalCost >= 0); + Assert.True(pair.Value.PrimaryCost >= 0); + } + } +} diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs new file mode 100644 index 00000000..e519653a --- /dev/null +++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs @@ -0,0 +1,403 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using AcDream.Content; +using AcDream.Content.CharGen; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using DatReaderWriter.Types; +using DatCharGen = DatReaderWriter.DBObjs.CharGen; +using DatObjDesc = DatReaderWriter.Types.ObjDesc; + +namespace AcDream.Content.Tests.CharGen; + +/// +/// Tests for 's pure projection +/// () against a hand-built +/// fixture — proves the field mapping without +/// depending on the installed DAT. The installed-DAT read path itself is +/// covered by . +/// +public sealed class ChargenTableReaderTests +{ + private static PStringBase Str(string value) + { + var s = new PStringBase(); + s.Value = value; + return s; + } + + private static QualifiedDataId Qdi(uint id) where T : DBObj, new() + { + var qdi = new QualifiedDataId(); + qdi.DataId = id; + return qdi; + } + + private static DatObjDesc MakeObjDesc(uint paletteId, byte partIndex, uint oldTex, uint newTex) + { + var od = new DatObjDesc(); + od.PaletteId = new PackedQualifiedDataId(); + od.PaletteId.DataId = paletteId; + + var sub = new SubPalette(); + sub.SubId = new PackedQualifiedDataId(); + sub.SubId.DataId = 0x04000099u; + sub.Offset = 8; + sub.NumColors = 24; + od.SubPalettes.Add(sub); + + var tex = new TextureMapChange(); + tex.PartIndex = partIndex; + tex.OldTexture = new PackedQualifiedDataId(); + tex.OldTexture.DataId = oldTex; + tex.NewTexture = new PackedQualifiedDataId(); + tex.NewTexture.DataId = newTex; + od.TextureChanges.Add(tex); + + var part = new AnimationPartChange(); + part.PartIndex = 2; + part.PartId = new PackedQualifiedDataId(); + part.PartId.DataId = 0x0100ABCDu; + od.AnimPartChanges.Add(part); + + return od; + } + + private static DatCharGen BuildFixture() + { + var table = new DatCharGen(); + + var area = new StartingArea(); + area.Name = Str("Holtburg"); + var position = new Position(); + position.CellId = 0xA9B40000u; + position.Frame = new Frame + { + Origin = new Vector3(1f, 2f, 3f), + Orientation = new Quaternion(0f, 0f, 0f, 1f), + }; + area.Locations.Add(position); + table.StartingAreas.Add(area); + + var heritage = new HeritageGroupCG(); + heritage.Name = Str("Aluvian"); + heritage.IconId = Qdi(0x06000001u); + heritage.SetupId = Qdi(0x02000010u); + heritage.EnvironmentSetupId = Qdi(0x02000020u); + heritage.AttributeCredits = 180u; + heritage.SkillCredits = 50u; + heritage.PrimaryStartAreas.Add(0); + heritage.SecondaryStartAreas.Add(0); + + var skill = new SkillCG(); + skill.Id = DatReaderWriter.Enums.SkillId.Axe; + skill.NormalCost = 4; + skill.PrimaryCost = 12; + heritage.Skills.Add(skill); + + var template = new TemplateCG(); + template.Name = Str("Soldier"); + template.IconId = Qdi(0x06000002u); + template.Title = 42u; + template.Strength = 40; + template.Endurance = 40; + template.Coordination = 40; + template.Quickness = 20; + template.Focus = 20; + template.Self = 20; + template.NormalSkills.Add(DatReaderWriter.Enums.SkillId.Axe); + template.PrimarySkills.Add(DatReaderWriter.Enums.SkillId.MeleeDefense); + heritage.Templates.Add(template); + + var sex = new SexCG(); + sex.Name = Str("Male"); + sex.Scale = 1000000u; + sex.SetupId = Qdi(0x02000030u); + sex.SoundTable = Qdi(0x22000001u); + sex.IconId = Qdi(0x06000003u); + sex.BasePalette = Qdi(0x04000001u); + sex.SkinPalSet = Qdi(0x04001001u); + sex.PhysicsTable = Qdi(0x0D000001u); + sex.MotionTable = Qdi(0x09000001u); + sex.CombatTable = Qdi(0x0F000001u); + sex.BaseObjDesc = MakeObjDesc(0x04000002u, 0, 0x05000001u, 0x05000002u); + sex.HairColors.Add(0x0Au); + sex.EyeColors.Add(0x0Bu); + sex.ClothingColors.Add(0x0Cu); + + var hairStyle = new HairStyleCG(); + hairStyle.IconId = Qdi(0x06000004u); + hairStyle.Bald = false; + hairStyle.AlternateSetup = 0x02000099u; + hairStyle.ObjDesc = MakeObjDesc(0x04000003u, 1, 0x05000003u, 0x05000004u); + sex.HairStyles.Add(hairStyle); + + var eyeStrip = new EyeStripCG(); + eyeStrip.IconId = Qdi(0x06000005u); + eyeStrip.BaldIconId = 0x06000006u; + eyeStrip.ObjDesc = MakeObjDesc(0x04000004u, 2, 0x05000005u, 0x05000006u); + eyeStrip.BaldObjDesc = MakeObjDesc(0x04000005u, 3, 0x05000007u, 0x05000008u); + sex.EyeStrips.Add(eyeStrip); + + var noseStrip = new FaceStripCG(); + noseStrip.IconId = Qdi(0x06000007u); + noseStrip.ObjDesc = MakeObjDesc(0x04000006u, 4, 0x05000009u, 0x0500000Au); + sex.NoseStrips.Add(noseStrip); + + var mouthStrip = new FaceStripCG(); + mouthStrip.IconId = Qdi(0x06000008u); + mouthStrip.ObjDesc = MakeObjDesc(0x04000007u, 5, 0x0500000Bu, 0x0500000Cu); + sex.MouthStrips.Add(mouthStrip); + + var headgear = new GearCG(); + headgear.Name = Str("Leather Cap"); + headgear.ClothingTable = Qdi(0x31000001u); + headgear.WeenieDefault = 300001u; + sex.Headgears.Add(headgear); + + var shirt = new GearCG(); + shirt.Name = Str("Tunic"); + shirt.ClothingTable = Qdi(0x31000002u); + shirt.WeenieDefault = 300002u; + sex.Shirts.Add(shirt); + + var pants = new GearCG(); + pants.Name = Str("Breeches"); + pants.ClothingTable = Qdi(0x31000003u); + pants.WeenieDefault = 300003u; + sex.Pants.Add(pants); + + var footwear = new GearCG(); + footwear.Name = Str("Boots"); + footwear.ClothingTable = Qdi(0x31000004u); + footwear.WeenieDefault = 300004u; + sex.Footwear.Add(footwear); + + heritage.Genders.Add(0, sex); + + table.HeritageGroups.Add(1u, heritage); + + return table; + } + + [Fact] + public void Project_MapsStarterAreasWithPositionsAndCellId() + { + ChargenOptions options = ChargenTableReader.Project(BuildFixture()); + + ChargenStarterArea area = Assert.Single(options.StarterAreas); + Assert.Equal(0, area.Index); + Assert.Equal("Holtburg", area.Name); + ChargenPosition position = Assert.Single(area.Locations); + Assert.Equal(0xA9B40000u, position.CellId); + Assert.Equal(new Vector3(1f, 2f, 3f), position.Origin); + Assert.Equal(new Quaternion(0f, 0f, 0f, 1f), position.Orientation); + } + + [Fact] + public void Project_MapsHeritageScalarFieldsAndStartAreaIndices() + { + ChargenOptions options = ChargenTableReader.Project(BuildFixture()); + + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions heritage)); + Assert.Equal("Aluvian", heritage.Name); + Assert.Equal(0x06000001u, heritage.IconId); + Assert.Equal(0x02000010u, heritage.SetupId); + Assert.Equal(0x02000020u, heritage.EnvironmentSetupId); + Assert.Equal(180u, heritage.AttributeCredits); + Assert.Equal(50u, heritage.SkillCredits); + Assert.Equal([0], heritage.PrimaryStartAreaIndices); + Assert.Equal([0], heritage.SecondaryStartAreaIndices); + Assert.False(heritage.IsOlthoi); + } + + [Fact] + public void Project_MapsSkillCostsKeyedByRawSkillId() + { + ChargenOptions options = ChargenTableReader.Project(BuildFixture()); + options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); + + uint axeId = (uint)DatReaderWriter.Enums.SkillId.Axe; + Assert.True(heritage.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost)); + Assert.Equal(axeId, cost.SkillId); + Assert.Equal(4, cost.NormalCost); + Assert.Equal(12, cost.PrimaryCost); + } + + [Fact] + public void Project_MapsTemplateAttributesAndSkillLists() + { + ChargenOptions options = ChargenTableReader.Project(BuildFixture()); + options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); + + ChargenTemplate template = Assert.Single(heritage.Templates); + Assert.Equal("Soldier", template.Name); + Assert.Equal(42u, template.TitleStringId); + Assert.Equal(new ChargenAttributeValues(40, 40, 40, 20, 20, 20), template.Attributes); + Assert.Equal(180, template.Attributes.Total); + Assert.Equal([(uint)DatReaderWriter.Enums.SkillId.Axe], template.NormalSkills); + Assert.Equal([(uint)DatReaderWriter.Enums.SkillId.MeleeDefense], template.PrimarySkills); + } + + [Fact] + public void Project_MapsGenderScalarsAndOptionLists() + { + ChargenOptions options = ChargenTableReader.Project(BuildFixture()); + options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); + + Assert.True(heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender)); + Assert.Equal("Male", gender!.Name); + Assert.Equal(1000000u, gender.Scale); + Assert.Equal(0x02000030u, gender.SetupId); + Assert.Equal(0x04000001u, gender.BasePaletteId); + Assert.Equal(0x04001001u, gender.SkinPalSetId); + Assert.Equal([0x0Au], gender.HairColors); + Assert.Equal([0x0Bu], gender.EyeColors); + Assert.Equal([0x0Cu], gender.ClothingColors); + Assert.True(gender.HasAnyAppearanceOptions); + + ChargenHairStyle hair = Assert.Single(gender.HairStyles); + Assert.Equal(0x06000004u, hair.IconId); + Assert.False(hair.Bald); + Assert.Equal(0x02000099u, hair.AlternateSetup); + + ChargenEyeStrip eye = Assert.Single(gender.EyeStrips); + Assert.Equal(0x06000005u, eye.IconId); + Assert.Equal(0x06000006u, eye.BaldIconId); + + ChargenFaceStrip nose = Assert.Single(gender.NoseStrips); + Assert.Equal(0x06000007u, nose.IconId); + ChargenFaceStrip mouth = Assert.Single(gender.MouthStrips); + Assert.Equal(0x06000008u, mouth.IconId); + + ChargenGearOption headgear = Assert.Single(gender.Headgears); + Assert.Equal("Leather Cap", headgear.Name); + Assert.Equal(0x31000001u, headgear.ClothingTableId); + Assert.Equal(300001u, headgear.WeenieDefaultId); + + Assert.Single(gender.Shirts); + Assert.Single(gender.Pants); + Assert.Single(gender.Footwear); + } + + [Fact] + public void Project_MapsObjDescPaletteSubPaletteTextureAndAnimPartChanges() + { + ChargenOptions options = ChargenTableReader.Project(BuildFixture()); + options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); + heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender); + + ChargenObjDesc baseDesc = gender!.BaseObjDesc; + Assert.Equal(0x04000002u, baseDesc.PaletteId); + ChargenSubPalette sub = Assert.Single(baseDesc.SubPalettes); + Assert.Equal(0x04000099u, sub.SubPaletteId); + Assert.Equal((byte)8, sub.Offset); + Assert.Equal((byte)24, sub.NumColors); + + ChargenTextureChange tex = Assert.Single(baseDesc.TextureChanges); + Assert.Equal((byte)0, tex.PartIndex); + Assert.Equal(0x05000001u, tex.OldTextureId); + Assert.Equal(0x05000002u, tex.NewTextureId); + + ChargenAnimPartChange part = Assert.Single(baseDesc.AnimPartChanges); + Assert.Equal((byte)2, part.PartIndex); + Assert.Equal(0x0100ABCDu, part.PartId); + + // The eye strip carries a SEPARATE bald ObjDesc from its normal one. + ChargenEyeStrip eye = Assert.Single(gender.EyeStrips); + Assert.Equal(0x04000004u, eye.ObjDesc.PaletteId); + Assert.Equal(0x04000005u, eye.BaldObjDesc.PaletteId); + Assert.NotEqual(eye.ObjDesc.PaletteId, eye.BaldObjDesc.PaletteId); + } + + [Fact] + public void Load_ReturnsEmptyOptions_WhenTableIsMissingFromDatSource() + { + var empty = new EmptyDatReaderWriter(); + + ChargenOptions options = ChargenTableReader.Load(empty); + + Assert.Empty(options.StarterAreas); + Assert.Empty(options.HeritagesById); + } + + /// Minimal stub whose Get + /// always misses — proves 's + /// missing-table tolerance without a live DAT. Mirrors the shape of + /// DatResolutionPrecedenceTests.ResolutionSource. + private sealed class EmptyDatReaderWriter : IDatReaderWriter + { + private readonly StubDatabase _db = new(); + + public string SourceDirectory => string.Empty; + public IDatDatabase Portal => _db; + public IDatDatabase Cell => _db; + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => _db; + public IDatDatabase Language => _db; + public IDatDatabase Local => _db; + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => default; + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public void Dispose() { } + + private sealed class StubDatabase : IDatDatabase + { + public DatDatabase Db => null!; + public int Iteration => 0; + public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) + { + value = default; + return false; + } + public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) + { + bytesRead = 0; + return false; + } + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => false; + public void Dispose() { } + } + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs new file mode 100644 index 00000000..22875865 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenAttributeMathTests.cs @@ -0,0 +1,86 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Tests for — retail's attribute-credit +/// budget port (CharGenState::SetHeritageGroup @ 0x005C67A0, +/// gmCharGenMainUI::DoFinish @ 0x004E9170). Uses synthetic budgets so +/// these don't depend on real DAT values. +/// +public sealed class ChargenAttributeMathTests +{ + [Fact] + public void RemainingCredits_IsBudgetMinusSumOfRawAttributeValues() + { + // Retail's floor is 10 per attribute; six attributes at the floor + // already "spend" 60 of the budget even before any points are added. + var values = new ChargenAttributeValues(10, 10, 10, 10, 10, 10); + + int remaining = ChargenAttributeMath.RemainingCredits(180u, values); + + Assert.Equal(120, remaining); + } + + [Fact] + public void RemainingCredits_ZeroWhenValuesExactlyConsumeBudget() + { + var values = new ChargenAttributeValues(40, 40, 40, 20, 20, 20); + Assert.Equal(180, values.Total); + + Assert.Equal(0, ChargenAttributeMath.RemainingCredits(180u, values)); + } + + [Fact] + public void IsFullySpent_TrueOnlyWhenRemainingIsExactlyZero() + { + var underspent = new ChargenAttributeValues(10, 10, 10, 10, 10, 10); + var exact = new ChargenAttributeValues(40, 40, 40, 20, 20, 20); + + Assert.False(ChargenAttributeMath.IsFullySpent(180u, underspent)); + Assert.True(ChargenAttributeMath.IsFullySpent(180u, exact)); + } + + [Fact] + public void IsFullySpent_FalseWhenCreditsRemain_MatchesRetailFinishGate() + { + // gmCharGenMainUI::DoFinish only refuses when remainingAtrbCredits + // > 0 (unspent credits) — it never special-cases "spent too much" + // because retail's own slider clamping never allows it. + var oneUnderBudget = new ChargenAttributeValues(40, 40, 40, 20, 20, 19); + + Assert.False(ChargenAttributeMath.IsFullySpent(180u, oneUnderBudget)); + Assert.Equal(1, ChargenAttributeMath.RemainingCredits(180u, oneUnderBudget)); + } + + [Theory] + [InlineData(9, false)] + [InlineData(10, true)] + [InlineData(100, true)] + [InlineData(101, false)] + public void IsWithinRange_EnforcesRetailFloorAndCeiling(int value, bool expected) + { + Assert.Equal(expected, ChargenAttributeMath.IsWithinRange(value)); + } + + [Fact] + public void AreAllWithinRange_FalseWhenAnySingleAttributeIsOutOfRange() + { + var withinRange = new ChargenAttributeValues(10, 100, 50, 50, 50, 50); + var oneTooLow = withinRange with { Focus = 9 }; + var oneTooHigh = withinRange with { Self = 101 }; + + Assert.True(ChargenAttributeMath.AreAllWithinRange(withinRange)); + Assert.False(ChargenAttributeMath.AreAllWithinRange(oneTooLow)); + Assert.False(ChargenAttributeMath.AreAllWithinRange(oneTooHigh)); + } + + [Fact] + public void AttributeValues_TotalSumsAllSixInWireOrder() + { + var values = new ChargenAttributeValues( + Strength: 1, Endurance: 2, Coordination: 3, Quickness: 4, Focus: 5, Self: 6); + + Assert.Equal(21, values.Total); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs new file mode 100644 index 00000000..154ea031 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs @@ -0,0 +1,77 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Tests for the / +/// lookup helpers, built from hand-crafted synthetic records (no DAT +/// dependency — the installed-DAT read path is covered separately by +/// AcDream.Content.Tests.CharGen). +/// +public sealed class ChargenOptionsTests +{ + private static ChargenHeritageOptions MakeHeritage(uint id, string name) => new( + HeritageId: id, + Name: name, + IconId: 0x06000001u, + SetupId: 0x02000001u, + EnvironmentSetupId: 0x02000002u, + AttributeCredits: 180u, + SkillCredits: 100u, + PrimaryStartAreaIndices: [0], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: new Dictionary(), + Templates: [], + GendersByKey: new Dictionary()); + + [Fact] + public void Empty_HasNoStarterAreasOrHeritages() + { + Assert.Empty(ChargenOptions.Empty.StarterAreas); + Assert.Empty(ChargenOptions.Empty.HeritagesById); + } + + [Fact] + public void TryGetHeritage_FindsRegisteredHeritageById() + { + var aluvian = MakeHeritage(1u, "Aluvian"); + var options = new ChargenOptions( + [], + new Dictionary { [1u] = aluvian }); + + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions found)); + Assert.Same(aluvian, found); + } + + [Fact] + public void TryGetHeritage_MissingIdReturnsFalse() + { + var options = ChargenOptions.Empty; + + Assert.False(options.TryGetHeritage(1u, out _)); + } + + [Fact] + public void TryGetStarterArea_ResolvesByIndexAndRejectsOutOfRange() + { + var area = new ChargenStarterArea(0, "Holtburg", []); + var options = new ChargenOptions([area], new Dictionary()); + + Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea found)); + Assert.Same(area, found); + Assert.False(options.TryGetStarterArea(1, out _)); + Assert.False(options.TryGetStarterArea(-1, out _)); + } + + [Theory] + [InlineData(1u, false)] // Aluvian + [InlineData(11u, false)] // Undead + [InlineData(12u, true)] // Olthoi + [InlineData(13u, true)] // OlthoiAcid + public void IsOlthoi_TrueOnlyForTheTwoOlthoiVariants(uint heritageId, bool expected) + { + ChargenHeritageOptions heritage = MakeHeritage(heritageId, "Test"); + + Assert.Equal(expected, heritage.IsOlthoi); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenSkillAdvancementSetTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenSkillAdvancementSetTests.cs new file mode 100644 index 00000000..cee2f806 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenSkillAdvancementSetTests.cs @@ -0,0 +1,80 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Tests for — the structural +/// 55-slot shape ACE's CharacterCreateInfo.Unpack requires on the +/// 0xF656 wire (numSkills must be exactly 55: reserved slot 0 plus +/// Chorizite's 54 named SkillId values, 1..54). +/// +public sealed class ChargenSkillAdvancementSetTests +{ + [Fact] + public void ToWireClasses_AlwaysProducesExactlyFiftyFiveEntries() + { + var set = new ChargenSkillAdvancementSet(); + + Assert.Equal(55, set.ToWireClasses().Count); + Assert.Equal(55, ChargenSkillAdvancementSet.SlotCount); + } + + [Fact] + public void DefaultState_EverySlotIsInactive() + { + var set = new ChargenSkillAdvancementSet(); + + IReadOnlyList wire = set.ToWireClasses(); + Assert.All(wire, value => Assert.Equal(0u, value)); + Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[1u]); + Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[54u]); + } + + [Fact] + public void Indexer_RoundTripsAssignedSkillState() + { + var set = new ChargenSkillAdvancementSet + { + [1u] = ChargenSkillAdvancementClass.Trained, + [54u] = ChargenSkillAdvancementClass.Specialized, + }; + + Assert.Equal(ChargenSkillAdvancementClass.Trained, set[1u]); + Assert.Equal(ChargenSkillAdvancementClass.Specialized, set[54u]); + + IReadOnlyList wire = set.ToWireClasses(); + Assert.Equal(0u, wire[0]); // reserved slot never assignable + Assert.Equal((uint)ChargenSkillAdvancementClass.Trained, wire[1]); + Assert.Equal((uint)ChargenSkillAdvancementClass.Specialized, wire[54]); + } + + [Fact] + public void Indexer_ReservedSlotZero_ReadsInactiveAndCannotBeSet() + { + var set = new ChargenSkillAdvancementSet(); + + Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[0u]); + Assert.Throws( + () => set[0u] = ChargenSkillAdvancementClass.Trained); + } + + [Theory] + [InlineData(55u)] + [InlineData(1000u)] + public void Indexer_SetOutOfRange_Throws(uint skillId) + { + var set = new ChargenSkillAdvancementSet(); + + Assert.Throws( + () => set[skillId] = ChargenSkillAdvancementClass.Trained); + } + + [Fact] + public void Indexer_GetOutOfRange_ReadsInactiveWithoutThrowing() + { + var set = new ChargenSkillAdvancementSet(); + + Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[55u]); + Assert.Equal(ChargenSkillAdvancementClass.Inactive, set[uint.MaxValue]); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs new file mode 100644 index 00000000..1ae40564 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs @@ -0,0 +1,86 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Tests for — retail's skill-credit +/// spend port (CharGenState::UpdateRemainingSkillCredits @ +/// 0x005C37C0). +/// +public sealed class ChargenSkillCreditMathTests +{ + private static readonly Dictionary Costs = new() + { + [1u] = new ChargenSkillCost(1u, NormalCost: 4, PrimaryCost: 12), // Axe + [11u] = new ChargenSkillCost(11u, NormalCost: 4, PrimaryCost: 12), // Sword + [24u] = new ChargenSkillCost(24u, NormalCost: 1, PrimaryCost: 3), // Run + // Deliberately no entry for skill id 2 (Bow) — heritage doesn't offer it. + }; + + [Fact] + public void ComputeSpent_IgnoresInactiveAndUntrainedSkills() + { + var advancement = new ChargenSkillAdvancementSet + { + [1u] = ChargenSkillAdvancementClass.Inactive, + [11u] = ChargenSkillAdvancementClass.Untrained, + }; + + Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + } + + [Fact] + public void ComputeSpent_ChargesNormalCostForTrainedSkills() + { + var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained }; + + Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + } + + [Fact] + public void ComputeSpent_ChargesPrimaryCostInsteadOfNormalCostForSpecializedSkills() + { + // Retail adds exactly one of NormalCost/PrimaryCost per skill, never + // both — PrimaryCost is the TOTAL cost to reach Specialized. + var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Specialized }; + + Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + } + + [Fact] + public void ComputeSpent_SumsAcrossMultipleTrainedAndSpecializedSkills() + { + var advancement = new ChargenSkillAdvancementSet + { + [1u] = ChargenSkillAdvancementClass.Trained, // 4 + [11u] = ChargenSkillAdvancementClass.Specialized, // 12 + [24u] = ChargenSkillAdvancementClass.Trained, // 1 + }; + + Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + } + + [Fact] + public void ComputeSpent_SkillWithNoCostEntryIsSkippedDefensively() + { + var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained }; + + Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + } + + [Fact] + public void RemainingCredits_IsTotalMinusSpent_AndMayGoNegativeUnlikeAttributes() + { + var advancement = new ChargenSkillAdvancementSet + { + [1u] = ChargenSkillAdvancementClass.Trained, + [11u] = ChargenSkillAdvancementClass.Specialized, + }; + + Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs)); + // Retail's Finish gate never checks remainingSkillCredits, so + // overspending relative to the (small, synthetic) budget below is a + // representable state, not a thrown exception. + Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs)); + } +} From 70d52b0da2e792d9fcbd6d0545b0198fc0e81f70 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 12:54:39 +0200 Subject: [PATCH 079/138] =?UTF-8?q?docs:=20Campaign=20CC=20ledger=20?= =?UTF-8?q?=E2=80=94=20CC1=20and=20CC2=20implemented,=20reviews=20in=20fli?= =?UTF-8?q?ght?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-15-character-creation-campaign.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 2cfe0504..42d8b340 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -200,8 +200,8 @@ the user gate. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| -| CC1 | — | | | | -| CC2 | — | | | | +| CC1 | implemented; review in flight | `04450041` | dispatched 2026-08-15 | 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 | +| CC2 | implemented (campaign-cc2); review in flight | `5eaad2c8` | dispatched 2026-08-15 | 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 | — | | | | | CC4 | — | | | | | CC5 | — | | | | From e77ebf100f5b252328a37697205e9c7cbe3fd6c0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 13:10:24 +0200 Subject: [PATCH 080/138] CC2 review fix round: latch scope narrowed, AD-100, creationFailed reason key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (MEDIUM): the correlation-latch docs claimed replies are never misattributed; in truth an overlapping send OVERWRITES the latch and the first reply routes to the newest request's event. Narrowed all three doc sites to the exact contract (single outstanding request; overlap refusal is CC3's Runtime verification gate, retail's DoFinish UNDEF-state rule) and pinned the overwrite behavior with OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest. F2 (LOW): filed register AD-100 for the drop-unless-armed deviation — retail's Handle_CharGenVerificationResponse@0x0055E8B0 has no armed gate and processes whatever arrives against its persistent verification state. F3 (LOW): doc note in CharacterCreate.cs — ACE double-sends NameInUse (IsCharacterNameAvailable runs twice; the first callback's return exits only the lambda), so the second reply hitting the drop path during a connected gate is EXPECTED, not a defect. F4 (LOW): creationFailed's enum-member key renamed name -> reason and the ATTEMPTED character name added as name, before any consumer shipped — one status vocabulary must not give the same key two meanings (characterCreated.name is a character name). Contract, writer, tailer, and shape-pinning tests updated in lockstep. F5 (LOW): the thread-id probe-note pointer now cites ProbeNetLogOutbound's doc comment, where the note actually lives. Fidelity fold (reviewer's positive note): the latch is retail's OWN discriminator one layer down — 0x0055E8B0 case 1 branches on GetVerificationState()==PENDING (create) vs not (restore) — now cited in both the latch doc and CharGenVerificationResponse.cs. Core.Net 994, Runtime 1667, Launcher.Core 324, all green Release. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- docs/plans/2026-08-14-launcher-campaign.md | 15 ++++--- .../Messages/CharGenVerificationResponse.cs | 7 +++ .../Messages/CharacterCreate.cs | 14 ++++++ src/AcDream.Core.Net/WorldSession.cs | 45 ++++++++++++++----- .../Status/StatusEvent.cs | 10 ++++- .../Status/StatusEventParser.cs | 1 + .../Session/SessionStatusWriter.cs | 12 +++-- .../WorldSessionCharacterCreationTests.cs | 43 ++++++++++++++++++ .../Status/StatusEventParserTests.cs | 10 +++-- .../Status/StatusFileTailerTests.cs | 5 ++- .../Session/SessionStatusWriterTests.cs | 10 +++-- 12 files changed, 143 insertions(+), 32 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8fb46bb4..f0da0f73 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 75 active rows (AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 76 active rows (AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -192,6 +192,7 @@ readiness/requeue adaptation. See | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | 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`; `ACCharGenData::GetVerificationState`; CC2 review F2 (2026-08-15) | | 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) | --- diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 19e08478..88a9ad47 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -170,7 +170,7 @@ line, writer opens `FileShare.Read`, tailer opens `enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`, `pluginFailed{plugin,error}`, `loginCommandFailed{commandIndex,command,error}`, -`characterCreated{guid,name}`, `creationFailed{code,name}`, +`characterCreated{guid,name}`, `creationFailed{code,reason,name}`, `disconnected{reason}`, `exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"` (ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH @@ -189,11 +189,14 @@ that payload's own field names and to read distinctly from `enteredWorld` — a freshly created character is logged straight in by retail without a fresh `characterList` (see that type's doc comment), so `characterCreated` can precede an `enteredWorld` for the same character -rather than replacing it. `creationFailed{code,name}` fires on any non-Ok -reply: `code` is the raw wire `CharGenVerificationResponse.Code` value, -`name` is that code's enum member name (e.g. `"NameInUse"`) so a reader -gets a stable readable reason without hard-coding the numeric mapping -itself. +rather than replacing it. `creationFailed{code,reason,name}` fires on any +non-Ok reply: `code` is the raw wire `CharGenVerificationResponse.Code` +value, `reason` is that code's enum member name (e.g. `"NameInUse"`) so a +reader gets a stable readable reason without hard-coding the numeric +mapping itself, and `name` is the ATTEMPTED character name so a launcher +can render "the name Bob is taken". (CC2 review F4: the enum member +originally rode the `name` key, colliding in meaning with +`characterCreated.name`; renamed before any consumer shipped.) `loginCommandFailed.commandIndex` is the zero-based index in the configured `loginCommands` array. `command` is the exact configured line and `error` is diff --git a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs index b506c2bd..11eab682 100644 --- a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs +++ b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs @@ -25,6 +25,13 @@ namespace AcDream.Core.Net.Messages; /// from "create response" by opcode or shape alone — WorldSession /// disambiguates by tracking which outbound request (restore vs. create) it /// is awaiting a reply to (see WorldSession's awaiting-request latch). +/// That latch is not merely a reasonable design — it is retail's OWN +/// mechanism: Handle_CharGenVerificationResponse@0x0055E8B0 case 1 +/// branches on the client's persistent chargen state, +/// GetVerificationState() == PENDING → new CharacterIdentity +/// + AddIdentity (a create it initiated), else → unpack into the +/// existing identity at slot (a restore). Same discriminator, one +/// layer down (CC2 review's fidelity note). /// /// /// diff --git a/src/AcDream.Core.Net/Messages/CharacterCreate.cs b/src/AcDream.Core.Net/Messages/CharacterCreate.cs index ca008060..56aecaeb 100644 --- a/src/AcDream.Core.Net/Messages/CharacterCreate.cs +++ b/src/AcDream.Core.Net/Messages/CharacterCreate.cs @@ -123,6 +123,20 @@ namespace AcDream.Core.Net.Messages; /// warns about for restore. WorldSession's awaiting-request latch /// must never assume a reply is coming. /// +/// +/// +/// ACE double-sends NameInUse (CC2 review F3). +/// CharacterHandler.CharacterCreateEx calls +/// IsCharacterNameAvailable TWICE — once at the top and once after +/// PlayerFactory.Create — and the first callback's return +/// exits only the lambda, so a duplicate name yields TWO 0xF643 +/// NameInUse replies. The first consumes the latch; the second hits +/// WorldSession's unrequested-response drop path (register AD-100) +/// and logs "unexpected CharacterGenerationVerificationResponse". During a +/// connected gate against ACE that log line is EXPECTED after a +/// duplicate-name rejection, not an acdream defect — and CC3's verification +/// gate must not treat the second reply as an error. +/// /// public static class CharacterCreate { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9d2397b9..0881b2cb 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -723,16 +723,35 @@ public sealed class WorldSession : IDisposable /// create requests share that opcode on the wire (see /// 's doc comment) with no /// self-describing discriminant, so this latch is the only thing that - /// tells the dispatcher which event to fire. Set by + /// tells the dispatcher which event to fire. Retail's own discriminator + /// is structurally the same latch: Handle_CharGenVerificationResponse + /// @0x0055E8B0 case 1 branches on + /// GetVerificationState() == PENDING → new CharacterIdentity + + /// AddIdentity (create) versus not-pending → unpack into the existing + /// identity at slot (restore). Set by /// / /// immediately before the send; cleared the moment a matching 0xF643 is /// dispatched (success OR parse failure — a malformed reply must not /// wedge the latch open forever) and on session teardown - /// (). Read/written only from the caller's frame - /// thread — the same single-threaded invariant every other per-session - /// field here (e.g. ) relies - /// on; is never invoked concurrently with - /// a send (see the class doc comment's thread-id probe note). + /// (). + /// + /// SCOPE, stated exactly (CC2 review F1): this latch correlates + /// the SINGLE outstanding request. It does NOT refuse overlapping + /// requests — a second send while one is outstanding OVERWRITES the + /// latch and the first request's reply is then delivered to the wrong + /// event. Refusing overlap is the CALLER's job, exactly as in retail: + /// gmCharGenMainUI::DoFinish@0x004e9170 only sends when the + /// verification state is UNDEF (CC3's Runtime verification gate owns + /// that rule here). The overwrite behavior is pinned by + /// WorldSessionCharacterCreationTests so CC3 cannot silently + /// regress against it. + /// + /// Read/written only from the caller's frame thread — the same + /// single-threaded invariant every other per-session field here (e.g. + /// ) relies on; + /// is never invoked concurrently with a + /// send (see 's doc comment — the + /// #260 thread-id probe note; CC2 review F5 corrected this pointer). /// private enum PendingCharGenVerificationRequest { @@ -2306,9 +2325,12 @@ public sealed class WorldSession : IDisposable /// /// Send retail CharacterRestore through the control queue. This is /// deliberately non-blocking because ACE silently drops unknown guids. - /// Arms the awaiting-request latch as Restore BEFORE the send so - /// a reply that arrives on a later Tick is never misattributed to a - /// different request (Campaign CC CC2). + /// Arms the awaiting-request latch as Restore BEFORE the send; + /// the latch correlates the SINGLE outstanding request — a second + /// create/restore sent while this one is outstanding overwrites it, and + /// refusing that overlap is the caller's job (CC3's verification gate). + /// See (Campaign CC + /// CC2). /// public void SendRestoreCharacter(uint characterId) { @@ -2325,7 +2347,10 @@ public sealed class WorldSession : IDisposable /// non-blocking, matching — ACE /// silently drops a request whose packed account name doesn't match the /// session's own account. Arms the awaiting-request latch as - /// Create BEFORE the send (Campaign CC CC2). + /// Create BEFORE the send; the latch correlates the SINGLE + /// outstanding request — overlap refusal is the caller's job (CC3's + /// verification gate; see + /// ) (Campaign CC CC2). /// public void SendCharacterCreation( string accountName, diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs index 2274baa8..6b63bfbe 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEvent.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -64,13 +64,19 @@ public sealed record CharacterCreatedStatusEvent : StatusEvent /// /// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate. /// is the raw wire -/// CharGenVerificationResponse.Code value; is that -/// code's enum member name (e.g. "NameInUse"). +/// CharGenVerificationResponse.Code value; is +/// that code's enum member name (e.g. "NameInUse"); +/// is the ATTEMPTED character name. The enum member +/// rode the name key until the CC2 review (F4) — same key, +/// different meaning than characterCreated.name — renamed before +/// any consumer shipped. /// public sealed record CreationFailedStatusEvent : StatusEvent { public required uint Code { get; init; } + public required string Reason { get; init; } + public required string Name { get; init; } } diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs index 9967613d..845d647c 100644 --- a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -272,6 +272,7 @@ public static class StatusEventParser T = t, SessionId = sessionId, Code = RequireUInt32(root, "code"), + Reason = RequireString(root, "reason"), Name = RequireString(root, "name"), }; diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs index e0ce20d0..e4c43e12 100644 --- a/src/AcDream.Runtime/Session/SessionStatusWriter.cs +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -232,12 +232,17 @@ public sealed class SessionStatusWriter /// /// Campaign CC CC2: a non-Ok 0xF643 response to an outbound /// CharacterCreate. is the raw wire - /// CharGenVerificationResponse.Code value; + /// CharGenVerificationResponse.Code value; /// is that code's enum member name (e.g. "NameInUse") so a /// launcher can render a readable reason without hard-coding the - /// server's numeric-to-dialog mapping itself. + /// server's numeric-to-dialog mapping itself; + /// is the ATTEMPTED character name — the thing a launcher most wants to + /// show ("the name Bob is taken"). The key was name for the enum + /// member until the CC2 review (F4): characterCreated.name is a + /// character name, and one status vocabulary must not give the same key + /// two meanings. Renamed before any consumer shipped. /// - public void CreationFailed(string sessionId, uint code, string name) => + public void CreationFailed(string sessionId, uint code, string reason, string name) => Write(new { v = VocabularyVersion, @@ -245,6 +250,7 @@ public sealed class SessionStatusWriter t = Now(), sessionId, code, + reason, name, }); diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs index 0fb4f7ef..ecf08b90 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCharacterCreationTests.cs @@ -199,6 +199,49 @@ public sealed class WorldSessionCharacterCreationTests Assert.Empty(createEvents); } + /// + /// CC2 review F1: pins the latch's stated scope EXACTLY. The latch + /// correlates the single outstanding request and does NOT refuse + /// overlap — a second send while one is outstanding OVERWRITES it, so + /// the first request's reply is delivered to the second request's + /// event. Refusing overlap is the caller's job (CC3's Runtime + /// verification gate, mirroring retail's DoFinish UNDEF-state gate). + /// If CC3 (or anyone) changes this transport-level behavior, this test + /// must change WITH it, deliberately. + /// + [Fact] + public void OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest() + { + using WorldSession session = CreateSession(); + session.GameMessageCapture = (_, _) => { }; + + session.SendRestoreCharacter(0x50000001u); + session.SendCharacterCreation( + "testaccount", + MakeCreateRequest(), + new uint[CharacterCreate.SkillAdvancementClassCount]); + Assert.Equal(PendingLatch.Create, ReadPendingLatch(session)); + + var restoreEvents = new List(); + var createEvents = new List(); + session.CharacterRestoreReceived += restoreEvents.Add; + session.CharacterCreateResponseReceived += createEvents.Add; + + // This reply is semantically the RESTORE's — but the overwritten + // latch routes it to the create event. That is the documented + // overwrite behavior, pinned here. + byte[] packet = BuildPacket( + BuildVerificationResponseBody( + (uint)CharGenVerificationResponse.Code.Ok, + 0x50000001u, + "Restored")); + InvokeProcessDatagram(session, packet); + + Assert.Empty(restoreEvents); + Assert.Single(createEvents); + Assert.Equal(PendingLatch.None, ReadPendingLatch(session)); + } + [Fact] public void ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed() { diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs index 10904453..e27f21c3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -88,16 +88,18 @@ public sealed class StatusEventParserTests var failed = Assert.IsType( StatusEventParser.Parse( - """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"name":"NameInUse"}""")); + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}""")); Assert.Equal(3u, failed.Code); - Assert.Equal("NameInUse", failed.Name); + Assert.Equal("NameInUse", failed.Reason); + Assert.Equal("Bob", failed.Name); } [Theory] [InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"name\":\"NewChar\"}")] [InlineData("{\"v\":1,\"e\":\"characterCreated\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"guid\":1342177296}")] - [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"name\":\"NameInUse\"}")] - [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"reason\":\"NameInUse\",\"name\":\"Bob\"}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"name\":\"Bob\"}")] + [InlineData("{\"v\":1,\"e\":\"creationFailed\",\"t\":\"2026-08-15T12:00:00Z\",\"sessionId\":\"s1\",\"code\":3,\"reason\":\"NameInUse\"}")] public void MalformedCharacterCreationEventsUseTheKnownEventFailurePath(string line) { var malformed = Assert.IsType(StatusEventParser.Parse(line)); diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs index bf30d037..4f9314aa 100644 --- a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -195,7 +195,7 @@ public sealed class StatusFileTailerTests : IDisposable AppendShared( """{"v":1,"e":"characterCreated","t":"2026-08-15T12:00:00Z","sessionId":"s1","guid":1342177296,"name":"NewChar"}""" + "\n" - + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"name":"NameInUse"}""" + + """{"v":1,"e":"creationFailed","t":"2026-08-15T12:00:01Z","sessionId":"s1","code":3,"reason":"NameInUse","name":"Bob"}""" + "\n"); var tailer = new StatusFileTailer(_path); @@ -207,7 +207,8 @@ public sealed class StatusFileTailerTests : IDisposable Assert.Equal("NewChar", created.Name); var failed = Assert.IsType(events[1]); Assert.Equal(3u, failed.Code); - Assert.Equal("NameInUse", failed.Name); + Assert.Equal("NameInUse", failed.Reason); + Assert.Equal("Bob", failed.Name); } [Fact] diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs index d9b0892a..90848778 100644 --- a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -110,7 +110,7 @@ public sealed class SessionStatusWriterTests writer.PluginFailed("s1", "acdream.bad", "failed"); writer.LoginCommandFailed("s1", 0, "", "unknown command"); writer.CharacterCreated("s1", 0x50000001u, "NewChar"); - writer.CreationFailed("s1", 3u, "NameInUse"); + writer.CreationFailed("s1", 3u, "NameInUse", "Bob"); writer.Disconnected("s1", "stopped"); writer.Exited("s1", 0, "disposed"); @@ -132,7 +132,7 @@ public sealed class SessionStatusWriterTests var writer = new SessionStatusWriter(file.Path); writer.CharacterCreated("s1", 0x50000010u, "NewChar"); - writer.CreationFailed("s1", 3u, "NameInUse"); + writer.CreationFailed("s1", 3u, "NameInUse", "Bob"); string[] lines = File.ReadAllLines(file.Path); Assert.Equal(2, lines.Length); @@ -149,8 +149,10 @@ public sealed class SessionStatusWriterTests Assert.Equal("creationFailed", failed.GetProperty("e").GetString()); Assert.Equal("s1", failed.GetProperty("sessionId").GetString()); Assert.Equal(3u, failed.GetProperty("code").GetUInt32()); - Assert.Equal("NameInUse", failed.GetProperty("name").GetString()); - AssertExactProperties(lines[1], "v", "e", "t", "sessionId", "code", "name"); + Assert.Equal("NameInUse", failed.GetProperty("reason").GetString()); + Assert.Equal("Bob", failed.GetProperty("name").GetString()); + AssertExactProperties( + lines[1], "v", "e", "t", "sessionId", "code", "reason", "name"); } [Fact] From 95e95bb6cb83703a36412ae60eb4a11e3f8ba98e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 13:12:48 +0200 Subject: [PATCH 081/138] =?UTF-8?q?docs:=20AD-100=20anchor=20greps=20now?= =?UTF-8?q?=20=E2=80=94=20CharGenState::GetVerificationState,=20not=20ACCh?= =?UTF-8?q?arGenData::=20(re-review=20residual)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/architecture/retail-divergence-register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f0da0f73..6504323a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -192,7 +192,7 @@ readiness/requeue adaptation. See | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | 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`; `ACCharGenData::GetVerificationState`; CC2 review F2 (2026-08-15) | +| 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-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) | --- From cb4703e8d54fbdad56d067bb5591213003af16cd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 13:32:40 +0200 Subject: [PATCH 082/138] =?UTF-8?q?fix(chargen):=20CC1=20review=20fix=20ro?= =?UTF-8?q?und=20=E2=80=94=20Custom=20is=20template=200,=20SkillTable=20co?= =?UTF-8?q?st=20fallback,=20frozen=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements all six Opus review findings against 04450041 (Campaign CC CC1 chargen data layer): - F1 (HIGH, blocking): ChargenTemplate's doc claimed "Custom" has no ChargenTemplate entry and cited two nonexistent addresses. Verified against the named retail decomp: gmCGProfessionPage::UpdateProfession @ 0x004821b0 resolves BOTH the highlighted button and the description string from CharGenState.template_ 0..6, and case 0 is button 0x100003d9 / ID_CharGen_CustomText. Custom IS template index 0 (the "Adventurer" row CC1 already found sitting at the attribute floor). CharGenState::SetTemplate @ 0x005C5A60 confirms every button (including Custom) calls CharGenState::ApplyTemplate @ 0x005C5080 when committing, so selecting Custom resets the sliders/skills to that row rather than leaving them untouched. - F2 (MEDIUM): retail's skill-cost lookup is two-tiered (ACCharGenData::GetSkillTrainedCost/GetSkillSpecializedCost @ 0x005C26D0/0x005C27D0 fall through to the global SkillTable, portal.dat 0x0E000004, on a heritage-list miss — confirmed against ACE's identical PlayerFactory.cs precedence). ChargenTableReader now also projects the global SkillTable into ChargenOptions.GlobalSkillCostsBySkillId, and ChargenSkillCreditMath.ComputeSpent/RemainingCredits check the heritage list first and the global list on a miss. Added an installed-DAT completeness assertion recording reality: the global table prices 38/54 advancement skill ids, every one of the 13 installed heritages ships exactly one heritage-specific override (always also priced globally), and 16 ids are genuinely uncostable in both tiers. Also filed a CC7 risk-item note: ACE's own heritage- override branch over-deducts on Specialize (PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; that is an ACE bug, not an acdream defect. - F3 (MEDIUM): every collection ChargenTableReader hands into the record model is now frozen at projection (ToFrozenDictionary/ToArray, matching MagicCatalog's house pattern), including both ChargenOptions.Empty dictionaries. - F4 (LOW): added a reflection guard test (ChargenNoChoriziteLeakTests) that walks every public AcDream.Core.CharGen member (property/indexer/constructor/method types, recursively through generic arguments) and fails if any resolves to the DatReaderWriter or a Chorizite* assembly. - F5 (LOW): ChargenGenderOptions.HasAnyAppearanceOptions's doc now states precisely what the installed-DAT gate proves (an OR across eight lists, for at least one gender per heritage) rather than the stronger claim it previously made, and explicitly calls out the three omitted color lists. Added a second installed-DAT gate that records per-list reality across every gender of every heritage — found complete, no empty lists anywhere in the installed DAT today. - F6 (LOW): ChargenOptions.TryGetHeritage/TryGetStarterArea now use [MaybeNullWhen(false)] instead of null! suppression, matching the house pattern already used elsewhere in the test suite. Fixed every call site this surfaced (more than the five originally estimated, since Content.Tests has TreatWarningsAsErrors). Core.Tests: 4737 passed / 1 skip (pre-existing, unrelated). Content.Tests: 145 passed / 0 skip. Co-Authored-By: Claude Fable 5 --- .../2026-08-15-character-creation-campaign.md | 30 ++- .../CharGen/ChargenTableReader.cs | 191 ++++++++++++------ .../CharGen/ChargenGenderOptions.cs | 21 +- src/AcDream.Core/CharGen/ChargenOptions.cs | 42 ++-- .../CharGen/ChargenSkillCreditMath.cs | 34 +++- src/AcDream.Core/CharGen/ChargenTemplate.cs | 38 +++- .../ChargenTableReaderInstalledDatTests.cs | 162 +++++++++++++-- .../CharGen/ChargenTableReaderTests.cs | 20 +- .../CharGen/ChargenNoChoriziteLeakTests.cs | 150 ++++++++++++++ .../CharGen/ChargenOptionsTests.cs | 12 +- .../CharGen/ChargenSkillCreditMathTests.cs | 76 ++++++- 11 files changed, 637 insertions(+), 139 deletions(-) create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 42d8b340..67d93676 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -187,6 +187,34 @@ worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7. arbiter. 7. `references/*` absent in worktrees (except WorldBuilder, uninitialized submodule) — agents read ACE/holtburger from the MAIN checkout path. +8. **CC7 landmine (found in the CC1 review fix round, 2026-08-15):** ACE's + `PlayerFactory.CreatePlayer` heritage-override branch + (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) + over-deducts skill credits when specializing a skill the active + heritage's own list prices. For a skill priced ONLY by the global + SkillTable, ACE correctly computes the incremental specialize cost via + `SkillBase.UpgradeCostFromTrainedToSpecialized` (= `SpecializedCost - + TrainedCost`) and charges `TrainSkill(trainedCost) + + SpecializeSkill(incrementalCost)` = the field's TOTAL, matching retail. + But when the heritage's own list has an entry, ACE sets + `specializedCost = skillGroup.PrimaryCost` directly — `PrimaryCost` is + already the TOTAL cost to reach Specialized (acdream's own + `ChargenSkillCost.PrimaryCost` convention, confirmed against retail) — + and then still charges `TrainSkill(NormalCost) + + SpecializeSkill(PrimaryCost)`, over-deducting by an extra `NormalCost` + credits versus what retail's client computed and what the player agreed + to spend. Practical impact for CC7's connected gate: a retail-legal + character build that specializes a skill the ACTIVE HERITAGE prices + (every one of the 13 installed heritages has exactly one such skill — + see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`) + may be REJECTED by local ACE with `FailedToSpecializeSkill` even though + acdream sent the byte-correct 0xF656 body. If CC7's gate hits this, + it is an ACE-side bug reproduced from its own source, NOT an acdream + wire or math defect — do not "fix" acdream's cost math to match ACE's + over-deduction. Register: file an AD row if CC7 needs a documented + workaround (e.g. picking a Specialized skill combination that avoids + the heritage-priced skill for the connected gate) rather than silently + adjusting acdream's send. ## Review protocol @@ -200,7 +228,7 @@ the user gate. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| -| CC1 | implemented; review in flight | `04450041` | dispatched 2026-08-15 | 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 | +| CC1 | implemented; review fix round complete | `04450041`, `459a87f2` | dispatched 2026-08-15 | 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 (`459a87f2`):** 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. | | CC2 | implemented (campaign-cc2); review in flight | `5eaad2c8` | dispatched 2026-08-15 | 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 | — | | | | | CC4 | — | | | | diff --git a/src/AcDream.Content/CharGen/ChargenTableReader.cs b/src/AcDream.Content/CharGen/ChargenTableReader.cs index 4b30a776..7426e539 100644 --- a/src/AcDream.Content/CharGen/ChargenTableReader.cs +++ b/src/AcDream.Content/CharGen/ChargenTableReader.cs @@ -1,22 +1,30 @@ +using System.Collections.Frozen; using AcDream.Core.CharGen; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; using CoreChargenObjDesc = AcDream.Core.CharGen.ChargenObjDesc; using DatCharGen = DatReaderWriter.DBObjs.CharGen; using DatObjDesc = DatReaderWriter.Types.ObjDesc; +using DatSkillTable = DatReaderWriter.DBObjs.SkillTable; namespace AcDream.Content.CharGen; /// /// Projects portal.dat's CharGen table (id , -/// retail ACCharGenData::Serialize @ 0x005C36D0) into acdream's +/// retail ACCharGenData::Serialize @ 0x005C36D0) PLUS the global +/// SkillTable (id ) into acdream's /// presentation-free tree. /// Mirrors MagicCatalog.Load's shape: one static entry point over -/// , no Chorizite types cross into the +/// , every returned collection is frozen at +/// projection (ToFrozenDictionary / ToArray, matching +/// MagicCatalog's pattern), and no Chorizite types cross into the /// returned model. Cross-checked against ACE's /// ACE.DatLoader.FileTypes.CharGen + /// ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG loaders, -/// which unpack the identical field order from the same DAT bytes. +/// which unpack the identical field order from the same DAT bytes, and +/// against ACE.DatLoader.FileTypes.SkillTable for the global +/// skill-cost fallback (see +/// ). /// public static class ChargenTableReader { @@ -24,48 +32,83 @@ public static class ChargenTableReader /// ACE.DatLoader.FileTypes.CharGen.FILE_ID). public const uint ChargenTableDid = 0x0E000002u; + /// Retail's global SkillTable DAT file id (ACE: + /// ACE.DatLoader.FileTypes.SkillTable.FILE_ID) — the fallback + /// retail's ACCharGenData::GetSkillTrainedCost @ 0x005C26D0 / + /// GetSkillSpecializedCost @ 0x005C27D0 read via + /// DBCache::GetFromEnumStatic(4, 2, 0x10000004) when a heritage's + /// own skill-cost list has no entry for a skill id. + public const uint SkillTableDid = 0x0E000004u; + /// - /// Loads and projects the installed CharGen table. Returns - /// if the table is missing from the - /// supplied dat source (mirrors MagicCatalog's tolerance for a - /// missing optional table — callers that require the table present - /// should check HeritagesById.Count themselves). + /// Loads and projects the installed CharGen table plus the global + /// SkillTable fallback. Returns if + /// the CharGen table is missing from the supplied dat source (mirrors + /// MagicCatalog's tolerance for a missing optional table — + /// callers that require the table present should check + /// HeritagesById.Count themselves). A missing global SkillTable + /// degrades to an empty fallback dictionary rather than failing the + /// whole load — the per-heritage costs (the common case) still work. /// public static ChargenOptions Load(IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(dats); DatCharGen? table = dats.Get(ChargenTableDid); - return table is null ? ChargenOptions.Empty : Project(table); + if (table is null) + return ChargenOptions.Empty; + + DatSkillTable? skillTable = dats.Get(SkillTableDid); + return Project(table, skillTable); } /// Pure projection from an already-loaded DAT record — split out /// from so tests can exercise it against - /// hand-built fixtures without a live DAT. - public static ChargenOptions Project(DatCharGen table) + /// hand-built fixtures without a live DAT. + /// is optional (mirrors 's + /// missing-table tolerance) and projects into + /// . + public static ChargenOptions Project(DatCharGen table, DatSkillTable? skillTable = null) { ArgumentNullException.ThrowIfNull(table); - var starterAreas = new List(table.StartingAreas.Count); + var starterAreas = new ChargenStarterArea[table.StartingAreas.Count]; for (int i = 0; i < table.StartingAreas.Count; i++) - starterAreas.Add(ProjectStarterArea(i, table.StartingAreas[i])); + starterAreas[i] = ProjectStarterArea(i, table.StartingAreas[i]); var heritagesById = new Dictionary(table.HeritageGroups.Count); foreach (KeyValuePair pair in table.HeritageGroups) heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value); - return new ChargenOptions(starterAreas, heritagesById); + var globalSkillCosts = new Dictionary(skillTable?.Skills.Count ?? 0); + if (skillTable is not null) + { + foreach (KeyValuePair pair in skillTable.Skills) + { + uint skillId = (uint)pair.Key; + globalSkillCosts[skillId] = new ChargenSkillCost( + skillId, + pair.Value.TrainedCost, + pair.Value.SpecializedCost); + } + } + + return new ChargenOptions( + starterAreas, + heritagesById.ToFrozenDictionary(), + globalSkillCosts.ToFrozenDictionary()); } private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area) { - var locations = new List(area.Locations.Count); - foreach (Position position in area.Locations) + var locations = new ChargenPosition[area.Locations.Count]; + for (int i = 0; i < area.Locations.Count; i++) { - locations.Add(new ChargenPosition( + Position position = area.Locations[i]; + locations[i] = new ChargenPosition( position.CellId, position.Frame.Origin, - position.Frame.Orientation)); + position.Frame.Orientation); } return new ChargenStarterArea(index, area.Name.Value, locations); } @@ -79,9 +122,9 @@ public static class ChargenTableReader skillCosts[skillId] = new ChargenSkillCost(skillId, skill.NormalCost, skill.PrimaryCost); } - var templates = new List(cg.Templates.Count); - foreach (TemplateCG template in cg.Templates) - templates.Add(ProjectTemplate(template)); + var templates = new ChargenTemplate[cg.Templates.Count]; + for (int i = 0; i < cg.Templates.Count; i++) + templates[i] = ProjectTemplate(cg.Templates[i]); var gendersByKey = new Dictionary(cg.Genders.Count); foreach (KeyValuePair pair in cg.Genders) @@ -95,22 +138,22 @@ public static class ChargenTableReader cg.EnvironmentSetupId.DataId, cg.AttributeCredits, cg.SkillCredits, - new List(cg.PrimaryStartAreas), - new List(cg.SecondaryStartAreas), - skillCosts, + cg.PrimaryStartAreas.ToArray(), + cg.SecondaryStartAreas.ToArray(), + skillCosts.ToFrozenDictionary(), templates, - gendersByKey); + gendersByKey.ToFrozenDictionary()); } private static ChargenTemplate ProjectTemplate(TemplateCG template) { - var normalSkills = new List(template.NormalSkills.Count); - foreach (var skillId in template.NormalSkills) - normalSkills.Add((uint)skillId); + var normalSkills = new uint[template.NormalSkills.Count]; + for (int i = 0; i < template.NormalSkills.Count; i++) + normalSkills[i] = (uint)template.NormalSkills[i]; - var primarySkills = new List(template.PrimarySkills.Count); - foreach (var skillId in template.PrimarySkills) - primarySkills.Add((uint)skillId); + var primarySkills = new uint[template.PrimarySkills.Count]; + for (int i = 0; i < template.PrimarySkills.Count; i++) + primarySkills[i] = (uint)template.PrimarySkills[i]; return new ChargenTemplate( template.Name.Value, @@ -129,33 +172,41 @@ public static class ChargenTableReader private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex) { - var hairStyles = new List(sex.HairStyles.Count); - foreach (HairStyleCG hair in sex.HairStyles) + var hairStyles = new ChargenHairStyle[sex.HairStyles.Count]; + for (int i = 0; i < sex.HairStyles.Count; i++) { - hairStyles.Add(new ChargenHairStyle( + HairStyleCG hair = sex.HairStyles[i]; + hairStyles[i] = new ChargenHairStyle( hair.IconId.DataId, hair.Bald, hair.AlternateSetup, - ProjectObjDesc(hair.ObjDesc))); + ProjectObjDesc(hair.ObjDesc)); } - var eyeStrips = new List(sex.EyeStrips.Count); - foreach (EyeStripCG eye in sex.EyeStrips) + var eyeStrips = new ChargenEyeStrip[sex.EyeStrips.Count]; + for (int i = 0; i < sex.EyeStrips.Count; i++) { - eyeStrips.Add(new ChargenEyeStrip( + EyeStripCG eye = sex.EyeStrips[i]; + eyeStrips[i] = new ChargenEyeStrip( eye.IconId.DataId, eye.BaldIconId, ProjectObjDesc(eye.ObjDesc), - ProjectObjDesc(eye.BaldObjDesc))); + ProjectObjDesc(eye.BaldObjDesc)); } - var noseStrips = new List(sex.NoseStrips.Count); - foreach (FaceStripCG strip in sex.NoseStrips) - noseStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc))); + var noseStrips = new ChargenFaceStrip[sex.NoseStrips.Count]; + for (int i = 0; i < sex.NoseStrips.Count; i++) + { + FaceStripCG strip = sex.NoseStrips[i]; + noseStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)); + } - var mouthStrips = new List(sex.MouthStrips.Count); - foreach (FaceStripCG strip in sex.MouthStrips) - mouthStrips.Add(new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc))); + var mouthStrips = new ChargenFaceStrip[sex.MouthStrips.Count]; + for (int i = 0; i < sex.MouthStrips.Count; i++) + { + FaceStripCG strip = sex.MouthStrips[i]; + mouthStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)); + } return new ChargenGenderOptions( genderKey, @@ -170,9 +221,9 @@ public static class ChargenTableReader sex.MotionTable.DataId, sex.CombatTable.DataId, ProjectObjDesc(sex.BaseObjDesc), - new List(sex.HairColors), + sex.HairColors.ToArray(), hairStyles, - new List(sex.EyeColors), + sex.EyeColors.ToArray(), eyeStrips, noseStrips, mouthStrips, @@ -180,35 +231,45 @@ public static class ChargenTableReader ProjectGearList(sex.Shirts), ProjectGearList(sex.Pants), ProjectGearList(sex.Footwear), - new List(sex.ClothingColors)); + sex.ClothingColors.ToArray()); } - private static List ProjectGearList(List gearList) + private static ChargenGearOption[] ProjectGearList(List gearList) { - var result = new List(gearList.Count); - foreach (GearCG gear in gearList) - result.Add(new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault)); + var result = new ChargenGearOption[gearList.Count]; + for (int i = 0; i < gearList.Count; i++) + { + GearCG gear = gearList[i]; + result[i] = new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault); + } return result; } private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc) { - var subPalettes = new List(objDesc.SubPalettes.Count); - foreach (SubPalette sub in objDesc.SubPalettes) - subPalettes.Add(new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors)); - - var textureChanges = new List(objDesc.TextureChanges.Count); - foreach (TextureMapChange change in objDesc.TextureChanges) + var subPalettes = new ChargenSubPalette[objDesc.SubPalettes.Count]; + for (int i = 0; i < objDesc.SubPalettes.Count; i++) { - textureChanges.Add(new ChargenTextureChange( - change.PartIndex, - change.OldTexture.DataId, - change.NewTexture.DataId)); + SubPalette sub = objDesc.SubPalettes[i]; + subPalettes[i] = new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors); } - var animPartChanges = new List(objDesc.AnimPartChanges.Count); - foreach (AnimationPartChange change in objDesc.AnimPartChanges) - animPartChanges.Add(new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId)); + var textureChanges = new ChargenTextureChange[objDesc.TextureChanges.Count]; + for (int i = 0; i < objDesc.TextureChanges.Count; i++) + { + TextureMapChange change = objDesc.TextureChanges[i]; + textureChanges[i] = new ChargenTextureChange( + change.PartIndex, + change.OldTexture.DataId, + change.NewTexture.DataId); + } + + var animPartChanges = new ChargenAnimPartChange[objDesc.AnimPartChanges.Count]; + for (int i = 0; i < objDesc.AnimPartChanges.Count; i++) + { + AnimationPartChange change = objDesc.AnimPartChanges[i]; + animPartChanges[i] = new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId); + } return new CoreChargenObjDesc(objDesc.PaletteId.DataId, subPalettes, textureChanges, animPartChanges); } diff --git a/src/AcDream.Core/CharGen/ChargenGenderOptions.cs b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs index 0ee4b2e0..da79e60c 100644 --- a/src/AcDream.Core/CharGen/ChargenGenderOptions.cs +++ b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs @@ -36,11 +36,22 @@ public sealed record ChargenGenderOptions( IReadOnlyList ClothingColors) { /// - /// Every appearance option list is non-empty for a playable gender — - /// CC1's installed-DAT gate asserts this holds for at least one gender - /// per heritage. A gender missing an option list can still be a valid - /// data shape (e.g. a bald-only heritage's hair styles), so callers - /// building UI should still defend against empty lists individually. + /// True when AT LEAST ONE of the eight lists below is non-empty (an OR + /// across all eight, not a per-list guarantee). Deliberately omits + /// , , and + /// — CC6's color-wheel controls need those + /// three independently of this property and must check them + /// separately. CC1's installed-DAT gate + /// (ChargenTableReaderInstalledDatTests.InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions) + /// only proves "at least one gender per heritage has at least one + /// non-empty list among these eight" — it does NOT prove every list is + /// non-empty for every gender of every heritage, and it does not cover + /// the three color lists at all; see + /// ChargenTableReaderInstalledDatTests.InstalledHeritages_AppearanceOptionListsRecordedPerListCompleteness + /// for the per-list installed-DAT reality. A gender missing an + /// individual option list can still be a valid data shape (e.g. a + /// bald-only heritage's hair styles), so callers building UI must still + /// defend against empty lists individually. /// public bool HasAnyAppearanceOptions => HairStyles.Count > 0 diff --git a/src/AcDream.Core/CharGen/ChargenOptions.cs b/src/AcDream.Core/CharGen/ChargenOptions.cs index e68555a7..218479f1 100644 --- a/src/AcDream.Core/CharGen/ChargenOptions.cs +++ b/src/AcDream.Core/CharGen/ChargenOptions.cs @@ -1,34 +1,52 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; + namespace AcDream.Core.CharGen; /// /// Top-level, presentation-free, immutable projection of retail's CharGen /// DAT table (portal.dat 0x0E000002, ACCharGenData::Serialize @ -/// 0x005C36D0). Production builds create this from the installed DAT -/// through Content's AcDream.Content.CharGen.ChargenTableReader.Load; -/// this type itself has no DAT/Chorizite dependency so it is safe to hand -/// to plugin-facing or test code. Everything a "typed chargen options -/// model" needs — starter areas, heritages, templates, per-gender -/// appearance option lists, skill costs — hangs off this one root. +/// 0x005C36D0) PLUS the global SkillTable (portal.dat 0x0E000004) that +/// retail falls back to when a heritage's own skill-cost list has no entry +/// for a given skill id. Retail's ACCharGenData::GetSkillTrainedCost @ +/// 0x005C26D0 / GetSkillSpecializedCost @ 0x005C27D0 both scan +/// the heritage's own list first and, on a miss (or an empty list), fall +/// through to DBCache::GetFromEnumStatic(4, 2, 0x10000004) — the +/// SAME global SkillTable every other skill-cost lookup in the client +/// reads — rather than treating the skill as free or invalid. See +/// . Production builds create this +/// from the installed DAT through Content's +/// AcDream.Content.CharGen.ChargenTableReader.Load; this type itself +/// has no DAT/Chorizite dependency so it is safe to hand to plugin-facing +/// or test code. Every collection is frozen/immutable at construction (a +/// caller cannot downcast an +/// back to a mutable and mutate this +/// process-shared model out from under other readers). Everything a "typed +/// chargen options model" needs — starter areas, heritages, templates, +/// per-gender appearance option lists, skill costs — hangs off this one +/// root. /// public sealed record ChargenOptions( IReadOnlyList StarterAreas, - IReadOnlyDictionary HeritagesById) + IReadOnlyDictionary HeritagesById, + IReadOnlyDictionary GlobalSkillCostsBySkillId) { public static ChargenOptions Empty { get; } = new( Array.Empty(), - new Dictionary()); + FrozenDictionary.Empty, + FrozenDictionary.Empty); - public bool TryGetHeritage(uint heritageId, out ChargenHeritageOptions heritage) => - HeritagesById.TryGetValue(heritageId, out heritage!); + public bool TryGetHeritage(uint heritageId, [MaybeNullWhen(false)] out ChargenHeritageOptions heritage) => + HeritagesById.TryGetValue(heritageId, out heritage); - public bool TryGetStarterArea(int index, out ChargenStarterArea area) + public bool TryGetStarterArea(int index, [MaybeNullWhen(false)] out ChargenStarterArea area) { if (index >= 0 && index < StarterAreas.Count) { area = StarterAreas[index]; return true; } - area = null!; + area = default; return false; } } diff --git a/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs index 2a8ab4fa..019c07e7 100644 --- a/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs +++ b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs @@ -7,23 +7,35 @@ namespace AcDream.Core.CharGen; /// for Specialized (never both), and subtract the total from the heritage's /// SkillCredits budget. No DAT/DatReaderWriter dependency — callers /// (CC3's Runtime owner) pass in the heritage's already-projected -/// lookup. +/// lookup plus the global SkillTable +/// fallback lookup (). /// public static class ChargenSkillCreditMath { /// /// Total credits spent across every Trained/Specialized skill in - /// . A skill with no cost entry for the - /// active heritage (i.e. the heritage doesn't offer it) is skipped — - /// retail's own UI can never reach that state, so this is defensive - /// rather than a documented retail behavior. + /// . Retail's cost lookup + /// (ACCharGenData::GetSkillTrainedCost @ 0x005C26D0 / + /// GetSkillSpecializedCost @ 0x005C27D0) is TWO-TIERED: it scans + /// the active heritage's own list + /// first, and only on a miss falls through to the global SkillTable + /// (, portal.dat 0x0E000004 via + /// DBCache::GetFromEnumStatic(4, 2, 0x10000004)). A skill id + /// missing from BOTH tiers is retail's -1/"no cost" case; this port + /// treats that as uncostable and skips it (mirrors ACE's identical + /// precedence in + /// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-196, + /// which seeds from SkillTable.SkillBaseHash[i] and then applies + /// a heritage override). /// public static int ComputeSpent( ChargenSkillAdvancementSet advancement, - IReadOnlyDictionary costsBySkillId) + IReadOnlyDictionary costsBySkillId, + IReadOnlyDictionary globalCostsBySkillId) { ArgumentNullException.ThrowIfNull(advancement); ArgumentNullException.ThrowIfNull(costsBySkillId); + ArgumentNullException.ThrowIfNull(globalCostsBySkillId); int spent = 0; for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) @@ -35,8 +47,11 @@ public static class ChargenSkillCreditMath continue; } - if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost)) + if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost) + && !globalCostsBySkillId.TryGetValue(skillId, out cost)) + { continue; + } spent += cls == ChargenSkillAdvancementClass.Specialized ? cost.PrimaryCost @@ -57,6 +72,7 @@ public static class ChargenSkillCreditMath public static int RemainingCredits( uint totalSkillCredits, ChargenSkillAdvancementSet advancement, - IReadOnlyDictionary costsBySkillId) => - checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId); + IReadOnlyDictionary costsBySkillId, + IReadOnlyDictionary globalCostsBySkillId) => + checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId, globalCostsBySkillId); } diff --git a/src/AcDream.Core/CharGen/ChargenTemplate.cs b/src/AcDream.Core/CharGen/ChargenTemplate.cs index 0bdc5ceb..484b12a4 100644 --- a/src/AcDream.Core/CharGen/ChargenTemplate.cs +++ b/src/AcDream.Core/CharGen/ChargenTemplate.cs @@ -1,14 +1,36 @@ namespace AcDream.Core.CharGen; /// -/// One profession preset (Bowhunter, Swashbuckler, Lifecaster, Warmage, -/// Wayfarer, Soldier, ...) offered on the Profession page -/// (UpdateProfession @ 0x00478d1c/0x0047a4a4-adjacent per the -/// campaign plan's recon; template buttons 0x100003da..df). "Custom" is NOT -/// one of these — it is retail's own free-attribute-assignment mode -/// selected by button 0x100003d9 and has no -/// entry. Retail schema: Template_CG::Serialize @ 0x005C0450 -/// (ACE's TemplateCG.Unpack mirrors the same field order). +/// One profession preset offered on the Profession page. Retail's +/// gmCGProfessionPage::UpdateProfession @ 0x004821b0 switches on +/// CharGenState.template_ and resolves BOTH the button to highlight +/// AND the description string from the SAME 0..6 index: 0 → button +/// 0x100003d9 / ID_CharGen_CustomText ("Custom"), 1 → 0x100003da +/// (Bow Hunter), 2 → 0x100003df (Swashbuckler), 3 → 0x100003db (Life +/// Caster), 4 → 0x100003dc (War Caster), 5 → 0x100003dd (Wayfarer), 6 → +/// 0x100003de (Soldier). "Custom" is therefore template index 0, NOT a +/// special UI-only mode with no data — it is a real +/// entry (confirmed against the installed DAT: each human heritage ships +/// this row as "Adventurer", sitting at the attribute floor rather than +/// spending the full credit budget). The seven profession buttons all wire +/// to CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60 (N = +/// 0..6, the second arg a "commit" flag); SetTemplate writes +/// template_ = N and, because N != 0xffffffff (retail's +/// no-template sentinel — never sent by any button), immediately calls +/// CharGenState::ApplyTemplate @ 0x005C5080, which re-reads that +/// template row's six attributes and skill list and re-applies them. +/// Selecting "Custom" therefore RESETS the attribute sliders and skill +/// picks to the Adventurer row's floor spread rather than leaving the +/// current values untouched — retail's own Custom-button handler +/// (gmCGProfessionPage::ListenToElementMessage case 0xed) calls +/// SetTemplate(state, 0, 1) then gmCGProfessionPage::UpdateToDefaultAttributes +/// @ 0x00482860 to refresh the slider UI to match. ACE's +/// PlayerFactory.CreatePlayer confirms the same indexing server-side: +/// it indexes heritageGroup.Templates[characterCreateInfo.TemplateOption] +/// with no special-cased "no template" branch +/// (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138). +/// Retail schema: Template_CG::Serialize @ 0x005C0450 (ACE's +/// TemplateCG.Unpack mirrors the same field order). /// public sealed record ChargenTemplate( string Name, diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs index 9c8f4f08..0753ef07 100644 --- a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs +++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs @@ -59,22 +59,22 @@ public sealed class ChargenTableReaderInstalledDatTests using var adapter = new DatCollectionAdapter(dats); ChargenOptions options = ChargenTableReader.Load(adapter); - Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian)); - Assert.Equal("Aluvian", aluvian.Name); + Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian)); + Assert.Equal("Aluvian", aluvian!.Name); - Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions gharundim)); - Assert.Equal("Gharu'ndim", gharundim.Name); + Assert.True(options.TryGetHeritage(GharundimId, out ChargenHeritageOptions? gharundim)); + Assert.Equal("Gharu'ndim", gharundim!.Name); - Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions sho)); - Assert.Equal("Sho", sho.Name); + Assert.True(options.TryGetHeritage(ShoId, out ChargenHeritageOptions? sho)); + Assert.Equal("Sho", sho!.Name); - Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions viamontian)); - Assert.Equal("Viamontian", viamontian.Name); + Assert.True(options.TryGetHeritage(ViamontianId, out ChargenHeritageOptions? viamontian)); + Assert.Equal("Viamontian", viamontian!.Name); - Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions olthoi)); - Assert.True(olthoi.IsOlthoi); - Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions olthoiAcid)); - Assert.True(olthoiAcid.IsOlthoi); + Assert.True(options.TryGetHeritage(OlthoiId, out ChargenHeritageOptions? olthoi)); + Assert.True(olthoi!.IsOlthoi); + Assert.True(options.TryGetHeritage(OlthoiAcidId, out ChargenHeritageOptions? olthoiAcid)); + Assert.True(olthoiAcid!.IsOlthoi); } [Fact] @@ -178,9 +178,9 @@ public sealed class ChargenTableReaderInstalledDatTests { indicesChecked++; Assert.True( - options.TryGetStarterArea(index, out ChargenStarterArea area), + options.TryGetStarterArea(index, out ChargenStarterArea? area), $"{heritage.Name}: start-area index {index} does not resolve into the shared StarterAreas list."); - Assert.False(string.IsNullOrEmpty(area.Name)); + Assert.False(string.IsNullOrEmpty(area!.Name)); } } @@ -201,8 +201,8 @@ public sealed class ChargenTableReaderInstalledDatTests using var adapter = new DatCollectionAdapter(dats); ChargenOptions options = ChargenTableReader.Load(adapter); - Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions aluvian)); - Assert.NotEmpty(aluvian.SkillCostsBySkillId); + Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian)); + Assert.NotEmpty(aluvian!.SkillCostsBySkillId); foreach (var pair in aluvian.SkillCostsBySkillId) { Assert.Equal(pair.Key, pair.Value.SkillId); @@ -211,4 +211,134 @@ public sealed class ChargenTableReaderInstalledDatTests Assert.True(pair.Value.PrimaryCost >= 0); } } + + /// + /// F2's completeness assertion: records EXACTLY what the installed DAT's + /// heritage-vs-global skill-cost coverage looks like (found + /// 2026-08-15), so 's two-tier + /// fallback (see ) + /// has a real regression gate instead of only synthetic unit fixtures. + /// Findings: the global SkillTable (portal.dat 0x0E000004) prices 38 of + /// the 54 advancement skill ids; EVERY one of the 13 installed + /// heritages ships EXACTLY one heritage-specific skill-cost override + /// (never zero, never more), and that one override always ALSO has a + /// global entry — no heritage in this DAT relies on a heritage-only + /// price the global table doesn't know about. The remaining 16 skill + /// ids are absent from BOTH tiers in every heritage — retail's genuine + /// -1/"no cost" case (non-advancement or deprecated skill slots such as + /// the pre-Skill-DID-remap gaps). If a future DAT drop changes any of + /// this shape, this test should fail and get updated with the new + /// reality, not be loosened silently. + /// + [Fact] + public void InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + Assert.Equal(38, options.GlobalSkillCostsBySkillId.Count); + + // Retail's -1/"no cost" case: absent from BOTH the global SkillTable + // AND every heritage's own list in the installed DAT. + uint[] expectedUncostableSkillIds = + [1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 17, 25, 26, 42, 53]; + + Assert.NotEmpty(options.HeritagesById); + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + Assert.Single(heritage.SkillCostsBySkillId); + + var uncostable = new List(); + foreach (KeyValuePair pair in heritage.SkillCostsBySkillId) + { + Assert.True( + options.GlobalSkillCostsBySkillId.ContainsKey(pair.Key), + $"{heritage.Name}: skill {pair.Key} is heritage-only with no global fallback entry — " + + "new ground truth found; update this test's recorded reality."); + } + + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + bool inHeritage = heritage.SkillCostsBySkillId.ContainsKey(skillId); + bool inGlobal = options.GlobalSkillCostsBySkillId.ContainsKey(skillId); + if (!inHeritage && !inGlobal) + uncostable.Add(skillId); + } + + Assert.Equal(expectedUncostableSkillIds, uncostable.OrderBy(id => id)); + } + } + + /// + /// F5's strengthened installed-DAT gate. + /// only proves an OR across eight lists for at least one gender per + /// heritage (see + /// above). This test records the STRONGER fact actually found in the + /// installed DAT (2026-08-15): every one of the eight + /// HasAnyAppearanceOptions lists, PLUS the three color lists it + /// deliberately excludes (, + /// , + /// ), is non-empty for + /// EVERY gender of EVERY one of the 13 heritages — even the sparse ones + /// (Gear Knight and both Olthoi variants ship as few as 1-2 entries per + /// list, but never 0). This is a fact about today's data, not a + /// structural guarantee the DAT format enforces — the type's own doc + /// comment still warns callers to defend against an empty list + /// individually, and a future DAT could reintroduce a gap (e.g. a + /// bald-only heritage's hair styles). If this test starts failing on a + /// new DAT drop, that is this gate doing its job, not a reader bug. + /// + [Fact] + public void InstalledHeritages_AppearanceOptionListsRecordedPerListCompleteness() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + Assert.NotEmpty(options.HeritagesById); + var emptyLists = new List(); + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach (KeyValuePair genderPair in heritage.GendersByKey) + { + ChargenGenderOptions gender = genderPair.Value; + string label = $"{heritage.Name}/{gender.Name} (heritage={heritage.HeritageId}, gender={genderPair.Key})"; + + void RecordIfEmpty(string listName, int count) + { + if (count == 0) + emptyLists.Add($"{label}: {listName}"); + } + + RecordIfEmpty(nameof(gender.HairStyles), gender.HairStyles.Count); + RecordIfEmpty(nameof(gender.EyeStrips), gender.EyeStrips.Count); + RecordIfEmpty(nameof(gender.NoseStrips), gender.NoseStrips.Count); + RecordIfEmpty(nameof(gender.MouthStrips), gender.MouthStrips.Count); + RecordIfEmpty(nameof(gender.Headgears), gender.Headgears.Count); + RecordIfEmpty(nameof(gender.Shirts), gender.Shirts.Count); + RecordIfEmpty(nameof(gender.Pants), gender.Pants.Count); + RecordIfEmpty(nameof(gender.Footwear), gender.Footwear.Count); + RecordIfEmpty(nameof(gender.HairColors), gender.HairColors.Count); + RecordIfEmpty(nameof(gender.EyeColors), gender.EyeColors.Count); + RecordIfEmpty(nameof(gender.ClothingColors), gender.ClothingColors.Count); + } + } + + Assert.Empty(emptyLists); + } } diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs index e519653a..1804053c 100644 --- a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs +++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderTests.cs @@ -203,8 +203,8 @@ public sealed class ChargenTableReaderTests { ChargenOptions options = ChargenTableReader.Project(BuildFixture()); - Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions heritage)); - Assert.Equal("Aluvian", heritage.Name); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage)); + Assert.Equal("Aluvian", heritage!.Name); Assert.Equal(0x06000001u, heritage.IconId); Assert.Equal(0x02000010u, heritage.SetupId); Assert.Equal(0x02000020u, heritage.EnvironmentSetupId); @@ -219,10 +219,10 @@ public sealed class ChargenTableReaderTests public void Project_MapsSkillCostsKeyedByRawSkillId() { ChargenOptions options = ChargenTableReader.Project(BuildFixture()); - options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage)); uint axeId = (uint)DatReaderWriter.Enums.SkillId.Axe; - Assert.True(heritage.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost)); + Assert.True(heritage!.SkillCostsBySkillId.TryGetValue(axeId, out ChargenSkillCost cost)); Assert.Equal(axeId, cost.SkillId); Assert.Equal(4, cost.NormalCost); Assert.Equal(12, cost.PrimaryCost); @@ -232,9 +232,9 @@ public sealed class ChargenTableReaderTests public void Project_MapsTemplateAttributesAndSkillLists() { ChargenOptions options = ChargenTableReader.Project(BuildFixture()); - options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage)); - ChargenTemplate template = Assert.Single(heritage.Templates); + ChargenTemplate template = Assert.Single(heritage!.Templates); Assert.Equal("Soldier", template.Name); Assert.Equal(42u, template.TitleStringId); Assert.Equal(new ChargenAttributeValues(40, 40, 40, 20, 20, 20), template.Attributes); @@ -247,9 +247,9 @@ public sealed class ChargenTableReaderTests public void Project_MapsGenderScalarsAndOptionLists() { ChargenOptions options = ChargenTableReader.Project(BuildFixture()); - options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage)); - Assert.True(heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender)); + Assert.True(heritage!.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender)); Assert.Equal("Male", gender!.Name); Assert.Equal(1000000u, gender.Scale); Assert.Equal(0x02000030u, gender.SetupId); @@ -288,8 +288,8 @@ public sealed class ChargenTableReaderTests public void Project_MapsObjDescPaletteSubPaletteTextureAndAnimPartChanges() { ChargenOptions options = ChargenTableReader.Project(BuildFixture()); - options.TryGetHeritage(1u, out ChargenHeritageOptions heritage); - heritage.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? heritage)); + heritage!.GendersByKey.TryGetValue(0, out ChargenGenderOptions? gender); ChargenObjDesc baseDesc = gender!.BaseObjDesc; Assert.Equal(0x04000002u, baseDesc.PaletteId); diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs new file mode 100644 index 00000000..b055a96a --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs @@ -0,0 +1,150 @@ +using System.Reflection; +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Pins the CC1 no-leak contract with a real assertion rather than a doc +/// comment. AcDream.Core references Chorizite.DatReaderWriter (for +/// TextureHelpers), so "no Chorizite type on any public CharGen +/// member" was convention only until this guard exists — nothing stopped a +/// future edit from putting e.g. a DatReaderWriter.Enums.SkillId +/// directly on a public property. This test walks every public type in the +/// AcDream.Core.CharGen namespace and asserts that no public +/// property, indexer, constructor parameter, or method return/parameter +/// type — nor any of their generic type arguments, recursively — comes +/// from the DatReaderWriter assembly or any assembly whose name +/// starts with Chorizite. +/// +public sealed class ChargenNoChoriziteLeakTests +{ + [Fact] + public void PublicCharGenSurface_NeverExposesChoriziteOrDatReaderWriterTypes() + { + Assembly coreAssembly = typeof(ChargenOptions).Assembly; + Type[] publicCharGenTypes = coreAssembly.GetTypes() + .Where(t => t.IsPublic && t.Namespace == "AcDream.Core.CharGen") + .ToArray(); + + // Guards the guard: if the namespace ever ends up empty (e.g. a + // rename), this test must fail loudly rather than vacuously pass. + Assert.True( + publicCharGenTypes.Length > 5, + $"Expected multiple public types in AcDream.Core.CharGen, found {publicCharGenTypes.Length}. " + + "Did the namespace get renamed or moved?"); + + var offenders = new List(); + + foreach (Type type in publicCharGenTypes) + { + foreach (PropertyInfo property in type.GetProperties( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + CheckSite(property.PropertyType, $"{type.FullName}.{property.Name} (property)", offenders); + + foreach (ParameterInfo indexParam in property.GetIndexParameters()) + { + CheckSite( + indexParam.ParameterType, + $"{type.FullName}.{property.Name}[{indexParam.Name}] (indexer parameter)", + offenders); + } + } + + foreach (ConstructorInfo ctor in type.GetConstructors( + BindingFlags.Public | BindingFlags.Instance)) + { + foreach (ParameterInfo param in ctor.GetParameters()) + { + CheckSite( + param.ParameterType, + $"{type.FullName}..ctor({param.Name}) (constructor parameter)", + offenders); + } + } + + foreach (MethodInfo method in type.GetMethods( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + // Property accessors, operators, and other compiler-emitted + // members are IsSpecialName; the property/indexer loop above + // already covers accessor types directly. + if (method.IsSpecialName) + continue; + + CheckSite(method.ReturnType, $"{type.FullName}.{method.Name} (return type)", offenders); + + foreach (ParameterInfo param in method.GetParameters()) + { + CheckSite( + param.ParameterType, + $"{type.FullName}.{method.Name}({param.Name}) (method parameter)", + offenders); + } + } + } + + Assert.True( + offenders.Count == 0, + "A Chorizite/DatReaderWriter type leaked onto a public AcDream.Core.CharGen member:\n" + + string.Join('\n', offenders)); + } + + private static void CheckSite(Type type, string site, List offenders) + { + foreach (Type candidate in FlattenTypeArguments(type)) + { + string? assemblyName = candidate.Assembly.GetName().Name; + if (assemblyName is null) + continue; + + bool isForbidden = + assemblyName.Equals("DatReaderWriter", StringComparison.OrdinalIgnoreCase) + || assemblyName.StartsWith("Chorizite", StringComparison.OrdinalIgnoreCase); + + if (isForbidden) + offenders.Add($"{site}: {candidate.FullName} (assembly '{assemblyName}')"); + } + } + + /// Yields itself plus every generic + /// type argument, array element type, and by-ref (out/ref parameter) + /// element type, recursively — so e.g. out IReadOnlyDictionary + /// <uint, ChargenSkillCost> is checked against + /// ChargenSkillCost, not just the outer dictionary type. + private static IEnumerable FlattenTypeArguments(Type type) + { + yield return type; + + if (type.IsByRef || type.IsPointer) + { + Type? element = type.GetElementType(); + if (element is not null) + { + foreach (Type inner in FlattenTypeArguments(element)) + yield return inner; + } + yield break; + } + + if (type.IsArray) + { + Type? element = type.GetElementType(); + if (element is not null) + { + foreach (Type inner in FlattenTypeArguments(element)) + yield return inner; + } + yield break; + } + + if (type.IsGenericType) + { + foreach (Type argument in type.GetGenericArguments()) + { + foreach (Type inner in FlattenTypeArguments(argument)) + yield return inner; + } + } + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs index 154ea031..c3dd5e2f 100644 --- a/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs +++ b/tests/AcDream.Core.Tests/CharGen/ChargenOptionsTests.cs @@ -37,9 +37,10 @@ public sealed class ChargenOptionsTests var aluvian = MakeHeritage(1u, "Aluvian"); var options = new ChargenOptions( [], - new Dictionary { [1u] = aluvian }); + new Dictionary { [1u] = aluvian }, + new Dictionary()); - Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions found)); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? found)); Assert.Same(aluvian, found); } @@ -55,9 +56,12 @@ public sealed class ChargenOptionsTests public void TryGetStarterArea_ResolvesByIndexAndRejectsOutOfRange() { var area = new ChargenStarterArea(0, "Holtburg", []); - var options = new ChargenOptions([area], new Dictionary()); + var options = new ChargenOptions( + [area], + new Dictionary(), + new Dictionary()); - Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea found)); + Assert.True(options.TryGetStarterArea(0, out ChargenStarterArea? found)); Assert.Same(area, found); Assert.False(options.TryGetStarterArea(1, out _)); Assert.False(options.TryGetStarterArea(-1, out _)); diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs index 1ae40564..5c20aa1b 100644 --- a/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs +++ b/tests/AcDream.Core.Tests/CharGen/ChargenSkillCreditMathTests.cs @@ -5,7 +5,9 @@ namespace AcDream.Core.Tests.CharGen; /// /// Tests for — retail's skill-credit /// spend port (CharGenState::UpdateRemainingSkillCredits @ -/// 0x005C37C0). +/// 0x005C37C0), including the two-tier heritage/global cost lookup +/// (ACCharGenData::GetSkillTrainedCost @ 0x005C26D0 / +/// GetSkillSpecializedCost @ 0x005C27D0). /// public sealed class ChargenSkillCreditMathTests { @@ -17,6 +19,10 @@ public sealed class ChargenSkillCreditMathTests // Deliberately no entry for skill id 2 (Bow) — heritage doesn't offer it. }; + /// Empty global fallback — tests that only exercise the + /// heritage tier pass this so a miss is a genuine both-tiers miss. + private static readonly Dictionary NoGlobalCosts = new(); + [Fact] public void ComputeSpent_IgnoresInactiveAndUntrainedSkills() { @@ -26,7 +32,7 @@ public sealed class ChargenSkillCreditMathTests [11u] = ChargenSkillAdvancementClass.Untrained, }; - Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] @@ -34,7 +40,7 @@ public sealed class ChargenSkillCreditMathTests { var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained }; - Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] @@ -44,7 +50,7 @@ public sealed class ChargenSkillCreditMathTests // both — PrimaryCost is the TOTAL cost to reach Specialized. var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Specialized }; - Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + Assert.Equal(12, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] @@ -57,15 +63,67 @@ public sealed class ChargenSkillCreditMathTests [24u] = ChargenSkillAdvancementClass.Trained, // 1 }; - Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + Assert.Equal(17, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); } [Fact] - public void ComputeSpent_SkillWithNoCostEntryIsSkippedDefensively() + public void ComputeSpent_SkillWithNoCostEntryInEitherTierIsSkipped() { + // Retail's -1/"no cost" case: absent from BOTH the heritage list AND + // the global SkillTable (ACCharGenData::GetSkillTrainedCost @ + // 0x005C26D0 returns 0xffffffff when even the global lookup misses). var advancement = new ChargenSkillAdvancementSet { [2u] = ChargenSkillAdvancementClass.Trained }; - Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs)); + Assert.Equal(0, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, NoGlobalCosts)); + } + + [Fact] + public void ComputeSpent_HeritageCostWinsOverGlobalCostWhenBothPresent() + { + // Skill id 1 (Axe) is priced differently by the heritage list and the + // global SkillTable — retail's lookup checks the heritage's own list + // FIRST and never consults the global table when the heritage + // provides its own entry. + var globalCosts = new Dictionary + { + [1u] = new ChargenSkillCost(1u, NormalCost: 999, PrimaryCost: 999), + }; + var advancement = new ChargenSkillAdvancementSet { [1u] = ChargenSkillAdvancementClass.Trained }; + + Assert.Equal(4, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts)); + } + + [Fact] + public void ComputeSpent_FallsBackToGlobalCostWhenHeritageListHasNoEntry() + { + // Skill id 2 (Bow) is absent from the heritage's own list but present + // in the global SkillTable fallback — retail charges the global cost + // rather than treating the skill as free. + var globalCosts = new Dictionary + { + [2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18), + }; + var advancement = new ChargenSkillAdvancementSet + { + [2u] = ChargenSkillAdvancementClass.Trained, + }; + + Assert.Equal(6, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts)); + } + + [Fact] + public void ComputeSpent_FallsBackToGlobalCostForSpecializedSkillsToo() + { + var globalCosts = new Dictionary + { + [2u] = new ChargenSkillCost(2u, NormalCost: 6, PrimaryCost: 18), + }; + var advancement = new ChargenSkillAdvancementSet + { + [2u] = ChargenSkillAdvancementClass.Specialized, + }; + + Assert.Equal(18, ChargenSkillCreditMath.ComputeSpent(advancement, Costs, globalCosts)); } [Fact] @@ -77,10 +135,10 @@ public sealed class ChargenSkillCreditMathTests [11u] = ChargenSkillAdvancementClass.Specialized, }; - Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs)); + Assert.Equal(84, ChargenSkillCreditMath.RemainingCredits(100u, advancement, Costs, NoGlobalCosts)); // Retail's Finish gate never checks remainingSkillCredits, so // overspending relative to the (small, synthetic) budget below is a // representable state, not a thrown exception. - Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs)); + Assert.Equal(-16, ChargenSkillCreditMath.RemainingCredits(0u, advancement, Costs, NoGlobalCosts)); } } From f3ef7baae2b9ac67d6875dcdda4b32a1ccecf648 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 13:41:37 +0200 Subject: [PATCH 083/138] =?UTF-8?q?docs+fix(chargen):=20CC1/CC2=20review?= =?UTF-8?q?=20closeout=20=E2=80=94=20R2/R3=20residuals=20closed,=20ledger?= =?UTF-8?q?=20final?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both narrow re-reviews returned CLOSED. This closeout takes the two cheap re-review residuals before CC3 takes references to the shared model: R2: every array handed into the typed chargen model is now wrapped in Array.AsReadOnly at the projection seam — a T[] behind IReadOnlyList was still downcast-mutable, and ChargenOptions is a process-shared singleton graph. R3: the no-Chorizite-leak guard now also walks public fields; every current type uses properties, but a public field would have slipped through the property-only walk. Ledger: CC1 fix-round sha corrected to cb4703e8 (the cell previously cited the pre-amend 459a87f2), CC1/CC2 rows flipped to REVIEW-CLOSED with the re-review outcomes, R1 (retail refunds +1 credit on a both-tier cost miss; port charges 0 — unreachable via retail's own listbox, noted for CC3) and the Olthoi-locked-to-template-0 decomp fact recorded for CC3/CC4. Core.Tests 4736/1 skip, Content.Tests 145/0, Release. Co-Authored-By: Claude Fable 5 --- .../2026-08-15-character-creation-campaign.md | 4 +- .../CharGen/ChargenTableReader.cs | 40 ++++++++++--------- .../CharGen/ChargenNoChoriziteLeakTests.cs | 17 ++++++-- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 67d93676..b5ef1fdb 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -228,8 +228,8 @@ the user gate. | Slice | Status | Commits | Review | Notes | |---|---|---|---|---| -| CC1 | implemented; review fix round complete | `04450041`, `459a87f2` | dispatched 2026-08-15 | 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 (`459a87f2`):** 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. | -| CC2 | implemented (campaign-cc2); review in flight | `5eaad2c8` | dispatched 2026-08-15 | 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 | +| 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 | — | | | | | CC4 | — | | | | | CC5 | — | | | | diff --git a/src/AcDream.Content/CharGen/ChargenTableReader.cs b/src/AcDream.Content/CharGen/ChargenTableReader.cs index 7426e539..127a215f 100644 --- a/src/AcDream.Content/CharGen/ChargenTableReader.cs +++ b/src/AcDream.Content/CharGen/ChargenTableReader.cs @@ -16,7 +16,7 @@ namespace AcDream.Content.CharGen; /// presentation-free tree. /// Mirrors MagicCatalog.Load's shape: one static entry point over /// , every returned collection is frozen at -/// projection (ToFrozenDictionary / ToArray, matching +/// projection (ToFrozenDictionary / Array.AsReadOnly, matching /// MagicCatalog's pattern), and no Chorizite types cross into the /// returned model. Cross-checked against ACE's /// ACE.DatLoader.FileTypes.CharGen + @@ -94,7 +94,7 @@ public static class ChargenTableReader } return new ChargenOptions( - starterAreas, + Array.AsReadOnly(starterAreas), heritagesById.ToFrozenDictionary(), globalSkillCosts.ToFrozenDictionary()); } @@ -110,7 +110,7 @@ public static class ChargenTableReader position.Frame.Origin, position.Frame.Orientation); } - return new ChargenStarterArea(index, area.Name.Value, locations); + return new ChargenStarterArea(index, area.Name.Value, Array.AsReadOnly(locations)); } private static ChargenHeritageOptions ProjectHeritage(uint heritageId, HeritageGroupCG cg) @@ -138,10 +138,10 @@ public static class ChargenTableReader cg.EnvironmentSetupId.DataId, cg.AttributeCredits, cg.SkillCredits, - cg.PrimaryStartAreas.ToArray(), - cg.SecondaryStartAreas.ToArray(), + Array.AsReadOnly(cg.PrimaryStartAreas.ToArray()), + Array.AsReadOnly(cg.SecondaryStartAreas.ToArray()), skillCosts.ToFrozenDictionary(), - templates, + Array.AsReadOnly(templates), gendersByKey.ToFrozenDictionary()); } @@ -166,8 +166,8 @@ public static class ChargenTableReader template.Quickness, template.Focus, template.Self), - normalSkills, - primarySkills); + Array.AsReadOnly(normalSkills), + Array.AsReadOnly(primarySkills)); } private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex) @@ -221,20 +221,20 @@ public static class ChargenTableReader sex.MotionTable.DataId, sex.CombatTable.DataId, ProjectObjDesc(sex.BaseObjDesc), - sex.HairColors.ToArray(), - hairStyles, - sex.EyeColors.ToArray(), - eyeStrips, - noseStrips, - mouthStrips, + Array.AsReadOnly(sex.HairColors.ToArray()), + Array.AsReadOnly(hairStyles), + Array.AsReadOnly(sex.EyeColors.ToArray()), + Array.AsReadOnly(eyeStrips), + Array.AsReadOnly(noseStrips), + Array.AsReadOnly(mouthStrips), ProjectGearList(sex.Headgears), ProjectGearList(sex.Shirts), ProjectGearList(sex.Pants), ProjectGearList(sex.Footwear), - sex.ClothingColors.ToArray()); + Array.AsReadOnly(sex.ClothingColors.ToArray())); } - private static ChargenGearOption[] ProjectGearList(List gearList) + private static IReadOnlyList ProjectGearList(List gearList) { var result = new ChargenGearOption[gearList.Count]; for (int i = 0; i < gearList.Count; i++) @@ -242,7 +242,7 @@ public static class ChargenTableReader GearCG gear = gearList[i]; result[i] = new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault); } - return result; + return Array.AsReadOnly(result); } private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc) @@ -271,6 +271,10 @@ public static class ChargenTableReader animPartChanges[i] = new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId); } - return new CoreChargenObjDesc(objDesc.PaletteId.DataId, subPalettes, textureChanges, animPartChanges); + return new CoreChargenObjDesc( + objDesc.PaletteId.DataId, + Array.AsReadOnly(subPalettes), + Array.AsReadOnly(textureChanges), + Array.AsReadOnly(animPartChanges)); } } diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs index b055a96a..cfe7f4ac 100644 --- a/tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs +++ b/tests/AcDream.Core.Tests/CharGen/ChargenNoChoriziteLeakTests.cs @@ -11,10 +11,13 @@ namespace AcDream.Core.Tests.CharGen; /// future edit from putting e.g. a DatReaderWriter.Enums.SkillId /// directly on a public property. This test walks every public type in the /// AcDream.Core.CharGen namespace and asserts that no public -/// property, indexer, constructor parameter, or method return/parameter -/// type — nor any of their generic type arguments, recursively — comes -/// from the DatReaderWriter assembly or any assembly whose name -/// starts with Chorizite. +/// property, field, indexer, constructor parameter, or method +/// return/parameter type — nor any of their generic type arguments, +/// recursively — comes from the DatReaderWriter assembly or any +/// assembly whose name starts with Chorizite. (The field walk +/// closes the CC1 re-review's R3 residual: every current type uses +/// properties, but public SkillId Foo; would otherwise slip +/// through.) /// public sealed class ChargenNoChoriziteLeakTests { @@ -51,6 +54,12 @@ public sealed class ChargenNoChoriziteLeakTests } } + foreach (FieldInfo field in type.GetFields( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + CheckSite(field.FieldType, $"{type.FullName}.{field.Name} (field)", offenders); + } + foreach (ConstructorInfo ctor in type.GetConstructors( BindingFlags.Public | BindingFlags.Instance)) { From 9a84230c4f968aff544828b351dca2771d2e902a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 14:26:10 +0200 Subject: [PATCH 084/138] =?UTF-8?q?feat(runtime):=20Campaign=20CC=20slice?= =?UTF-8?q?=20CC3=20=E2=80=94=20RuntimeCharacterCreationState?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports retail's CharGenState as the one Runtime-owned character-creation state machine, mirroring RuntimeCharacterSelectionState's exact pattern (snapshot/delta/event-stream, borrow-only view, generation-gated commands, one mutable owner, no App types). Every command ports a named retail function: SetHeritageGroup, SetGender, SetTemplate/ApplyTemplate (Custom = template 0, Olthoi force-lock), the six attribute setters plus GetAbsRemainingCredits/BalanceAttributes (retail's literal round-robin order and fairness cursor), SetSkillLevel plus ResetSkillLevels' free-skill baseline (reusing CC1's ChargenSkillCreditMath two-tier cost lookup verbatim), RandomizeStartArea, and DoFinish's complete gate sequence (empty name / unspent attribute credits / already-Pending / client-side roster-vs-slotCount cap). LiveSessionController gained a sibling IRuntimeCharacterCreationCommands implementation, a CreateCharacter wire hook, and a response handler that reuses existing machinery rather than inventing new paths: the Ok identity is appended to the roster via RuntimeCharacterSelectionState's own ApplyRoster, and the "log straight in" behavior reuses the private EnterSelectedCore. ILiveSessionLifecycleHost gained two default-no-op hooks (ApplyCharacterCreated/ApplyCreationFailed) so AcDream.App needs zero changes to keep compiling; wiring them to the status stream is a CC4 follow-up. Filed four divergence-register rows for the corners deliberately not ported: the FPU-unrecoverable FitTemplateToCharacter auto-detect (AP-207, ACE only reads the field for title text), the per-style color-count approximation (AP-208, CC1's model has no per-style palette data), the classID DAT-DID placeholder (AP-209, ACE ignores the field), and ApplyTemplate's atomic-vs-sequential attribute apply (AP-210). 34 new tests: full state-machine coverage (every Finish gate, every rejection-code mapping, duplicate-NameInUse tolerance, Olthoi lock, attribute balance/lock interaction, uncostable-skill rejection) plus a LiveSessionController integration suite proving the wire send is exactly 55 skill slots (decoded from a real WorldSession + GameMessageCapture) and the full Ok/rejection round trip through WorldSession.ProcessDatagram. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 6 +- .../2026-08-15-character-creation-campaign.md | 2 +- src/AcDream.Runtime/GameRuntimeCommands.cs | 80 + .../DirectGameRuntimeCommandAdapter.cs | 2 + .../Session/LiveSessionController.cs | 407 ++++- .../Session/RuntimeCharacterCreationState.cs | 1469 +++++++++++++++++ .../RuntimeCharacterCreationStateFixture.cs | 206 +++ .../RuntimeCharacterCreationStateTests.cs | 505 ++++++ ...SessionControllerCharacterCreationTests.cs | 371 +++++ 9 files changed, 3043 insertions(+), 5 deletions(-) create mode 100644 src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs create mode 100644 tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs create mode 100644 tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6504323a..4c19a3af 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -197,7 +197,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 142 active rows (AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 146 active rows (AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -383,6 +383,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-190 | **Filed 2026-08-10 (Campaign CH slice CH6c — window opacity + transparency setting; retires AP-40). AMENDED 2026-08-10 at the CH6c review-fix round: reworded (2), added (3)/(4).** Four divergences from retail's focus-driven window opacity, all decomp-verified (`docs/research/2026-08-09-chat-retail-window-shell.md` §3). (1) SCOPE: retail's `ChatInterface::SetOpacity`/`SetDefaultOpacity`/`SetActiveOpacity` only ever run on `ChatInterface`-derived windows (the main chat window + the four floaties) — every other retail window (vitals, toolbar, inventory, ...) has no opacity fade at all. acdream's `RetailWindowOpacityController` subscribes to `RetailWindowManager.WindowRegistered` and applies the SAME focus-driven fade to every window the manager ever registers, so the one Settings → Chat tab transparency slider pair affects the whole retained UI. (2) DEFAULT VALUE — REWORDED at the review-fix round: retail's shipped defaults are PER WINDOW CLASS — the base `ChatInterface` ctor (`0x004F4550`) sets DefaultOpacity=0.5/ActiveOpacity=1.0, but `gmMainChatUI`'s own ctor (`0x004CD0F0`, called after the base ctor) overrides DefaultOpacity to 1.0 (the main window is ALWAYS fully opaque in both states); `gmFloatyChatUI::Create` (`0x004CE2C0`) calls the base ctor directly with no override, so only the four floating windows keep 0.5/1.0. acdream originally shipped the base ChatInterface value (0.5/1.0) as ONE shared global default applied to EVERY registered window — combined with (1)'s scope extension this faded the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity out of the box, including several windows that can never take keyboard focus at all and so were PERMANENTLY stuck at 0.5. Fixed at the review round to `gmMainChatUI`'s 1.0/1.0 override as the shared default instead: this reduces the remaining divergence to acdream's four floating chat windows shipping OPAQUE where retail's floaties ship 0.5-while-idle — user-settable via the same Settings → Chat opacity slider pair, so it is now a default-VALUE divergence only, not a missing mechanism. (3) EASING (new, filed at the review-fix round): retail's `ChatInterface::ListenToGlobalMessage @0x004F3840` — armed on the focus element-messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` at `0x004F5275` via `UIListener::RegisterForGlobalMessage(this, 3)` — eases the live opacity toward its target by 5% of the target-delta per tick, unregistering from the global tick once within FP-epsilon of the target. acdream's `RetailWindowOpacityController.Apply` snaps to the target opacity immediately on every focus-change event; porting the per-tick lerp needs a UI frame-tick hook the controller does not have today, so it is deferred rather than implemented this round. (4) FOCUS PREDICATE (new, filed at the review-fix round): retail's `ChatInterface::IsTextEntryFocused @0x004F30A0` tests specifically whether `GetFocusDescendant(rootElement) == this->m_chatEntry` — the chat ENTRY FIELD, not the window generally. acdream's `RetailWindowHandle.DescendantFocusChanged` fires whenever ANY focusable descendant of the window gains focus, a strictly broader predicate for any window with more than one focusable child. The linked active>=default invariant itself (`SetDefaultOpacity`/`SetActiveOpacity`'s mutual-correction bodies) IS ported exactly — `ChatOpacityLink` in `AcDream.UI.Abstractions`. | `src/AcDream.App/UI/RetailWindowOpacityController.cs`; `src/AcDream.App/UI/RetailWindowManager.cs` (`WindowRegistered`); `src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` (`DefaultOpacity`/`ActiveOpacity`) | Extending the fade to every window is the shape the user's requested "transparency setting" actually wants (a general UI preference, not a chat-only one); shipping the shared default at 1.0 keeps the out-of-box render retail-identical for the 11 non-chat windows AND the main chat window (the windows retail keeps opaque, several of which can never take focus at all), while the Settings → Chat transparency slider remains fully user-settable for anyone who wants the four floaties' retail translucence back. (3) and (4) are both presentation-only refinements — the fade direction and the linked-invariant math stay retail-exact, only the transition curve (snap vs. 5%-per-tick ease) and the focus predicate's granularity (any descendant vs. the text-entry specifically) diverge — so recording them without implementing the frame-tick hook (3) or narrowing the focus event (4) is the correct scope for a review-fix round rather than opening new implementation work | A user who compares acdream's default install against retail side-by-side now sees the 11 non-chat windows AND the main chat window matching (opaque); only the four floating chat windows still diverge (opaque vs. retail's 50%-while-idle) until the slider is dragged. (3) is visible as the opacity change happening in a single frame instead of retail's ~20-tick fade — low severity, since the START and END states are both retail-exact, only the transition is instant instead of eased. (4) is visible on any window with more than one distinct focusable descendant (e.g. a settings panel with several controls): acdream stays at ActiveOpacity while ANY of them holds focus, where retail would already have faded back to DefaultOpacity once focus left the specific text-entry element — for single-focusable-child windows (most of the retained UI today) the two predicates coincide and there is no observable difference | `ChatInterface::ChatInterface @0x004F4550`; `gmMainChatUI::gmMainChatUI @0x004CD0F0`; `gmFloatyChatUI::Create @0x004CE2C0`; `ChatInterface::SetDefaultOpacity @0x004F3BC0`/`SetActiveOpacity @0x004F3C40`; `ChatInterface::ListenToGlobalMessage @0x004F3840`; `ChatInterface::IsTextEntryFocused @0x004F30A0`; global-message arming switch @0x004F5275 (`UIListener::RegisterForGlobalMessage(this, 3)` on element messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E`) | | AP-191 | **Filed 2026-08-10 (Campaign CH round 4, user-gate items 1+2 — retail two-plane glyph outline + authored SpewBox/chat text style, `docs/research/2026-08-10-retail-ui-text-style.md`).** The chat transcript's authored BASE STYLE (`0x10000372` in layout `0x2100003F`) carries a `0x1C`/`0x1D` pair alongside its `0x1A`/`0x1B` — `0x1D` (`TagFontColor[]`) is confirmed authored `ARGB(255,0,178,0)` (green), and `0x1C` is UNVERIFIED but most likely `TagFontDID` by symmetry with `0x1D` (both are pull-based, no `OnSetAttribute` case, unlike `0x1A`/`0x1B`/`0x21`/`0x22` which this round's commit DOES import). Retail's `AppendTextWithFont` selects a font/colour PAIR per appended run via `SetFontDIDNum`/`SetFontColorNum`, so a message's `[General]`-style channel tag can render in a distinct colour/font from the rest of the line — a capability `UiText.Line` does not have (one `Color` per whole line, no sub-line run concept). Landing this needs a per-run tag boundary threaded from `ChatTranscriptRenderer.BuildLines` through `UiText`'s line model into `UiRenderContext.DrawStringDat`, deliberately out of this round's scope (Fix 5 only changed the DEFAULT/uncolored-run seed, not the run model). `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`BuildLines`); `src/AcDream.App/UI/UiText.cs` (`Line`) | The default-fill fix (this same commit) is the higher-value, lower-risk half of retail's text-style gap for the transcript; a per-run tag concept is a larger structural change (touches the line model every transcript consumer reads) better landed as its own reviewed slice than folded into a text-style bugfix commit | Retail's `[General]`/channel-name tag prefix on a chat line renders the SAME colour as the rest of the line in acdream instead of green, and any authored tag-specific font goes unused — cosmetic only, the message text itself is unaffected | `UIElement_Text::AppendTextWithFont @0x00469de0`; `UIElement_Text::SetFontColorHelper @0x00466ac0`; `docs/research/2026-08-10-retail-ui-text-style.md` §2.3/§2.6 | | AP-192 | **Filed 2026-08-10 (Campaign CH round-5 polish, review item S2 — non-UiText outline paths).** Authored glyph outline `0x21`/outline color `0x22` now reach every text-bearing retained widget (`UiText`, `UiButton`, `UiDatElement`, `UiField`, `UiMeter`, `UiMenu`, `UiCatalogSlot` — the last two settable-only, having no authored build path), seeded ONCE from the element's effective-default state via `ElementReader.ApplyCanonicalLegacyProjection`'s `TryGetEffectiveProperty` (DirectState-then-effective-default rule). Retail instead re-resolves text properties on every UI STATE CHANGE — a button entering state `0x3` whose StateDesc authors `0x21=true` gains the outline for the duration of that state. The authored data hits this today: the dialog panel's two buttons (`0x2100003C` elements `0x17`/`0x19`), the character panel button `0x10000535`, and the combat panel button `0x100000B2` each author `0x21=true` in state `0x3` ONLY (DefaultStateId=1 → no outline at effective-default; `0x100000B2` also authors DirectState `0x21=true`, which the canonical rule DOES honor). The same seed-once shape already governs `UiText` (its `ApplyDatState` re-resolves `0x1B` FontColor per state but not `0x21`/`0x22`). `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (BuildButton/BuildCheckbox/BuildMeter/BuildText + the editable-field branch); `src/AcDream.App/UI/UiText.cs` (`ApplyDatState`) | Seed-once from the canonical effective state is strictly closer to retail than the pre-round-5 any-state first-wins scan (which lit those state-`0x3` outlines PERMANENTLY); the widening this row rides in on makes every ALWAYS-outlined authored element (DirectState/default-state authors) render retail-correct, and per-state re-resolution needs a property-application pass on the existing `TrySetRetailState` path — a reviewed slice of its own, not a polish-commit fold-in | A button that retail outlines only in a specific UI state (the four state-`0x3` authors above — state 3 is a hover/highlight-class state) never shows that transient outline in acdream; conversely nothing over-renders, since the effective-default resolution correctly yields outline-off for those elements | `UIElement_Text::SetOutline @0x0046a81c` (`m_bitField & 0x10`); `UIElement_Text::DrawSelf @0x00467aa0` (two-pass outline+fill); LayoutDesc fixtures `dialogs_2100003C.json` (`0x17`/`0x19`), `character_2100002E.json` (`0x10000535`), `combat_21000073.json` (`0x100000B2`) | +| AP-207 | **Filed 2026-08-15 at Campaign CC slice CC3 (character-creation state machine).** Retail re-detects the closest-matching Profession template on every attribute-slider edit (`CharGenState::FitTemplateToCharacter @ 0x005C6130`, called from `gmCGProfessionPage::SetAttribValue @ 0x00482890` after every raise/lower), auto-flipping `template_` to whichever preset the current attribute+skill spread scores closest to (or to `0xFFFFFFFF`/"no match" when nothing fits within tolerance) via an FPU-heavy weighted-distance heuristic (`TEMPLATE_WEIGHT_ATTRIBUTES`/`_TRAINED_SKILLS`/`_SPECIALIZED_SKILLS`). Several of the function's float operations are literally unrecoverable in the named decomp (`/* unimplemented {fild/fidiv/fmul/fadd ...} */` markers Binary Ninja could not translate), consistent with this project's existing x87-blocked precedent. `RuntimeCharacterCreationState` never re-derives `Template` from attribute/skill edits — it only changes via an explicit `SelectTemplate` command, matching `SetTemplate @ 0x005C5A60`'s own commit path. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TrySetAttribute`, `TrySetSkillLevel` — neither calls a `FitTemplateToCharacter` port) | ACE's `PlayerFactory.CreatePlayer` only reads `TemplateOption` for the character's display title/name text (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138`) — it never re-validates attributes/skills against the named template, so a stale `Template` value has no server-side consequence; porting an FPU-unrecoverable heuristic for a value ACE ignores is not a good trade. | A free-editing user who drifts away from their chosen template's exact spread keeps seeing that template's name/button highlighted instead of retail's live re-detection (which might silently flip to a different preset name, or to "Custom"); this is presentation-only until CC4/CC5 build the Profession page's button highlight. | `CharGenState::FitTemplateToCharacter @ 0x005C6130`; `gmCGProfessionPage::SetAttribValue @ 0x00482890`; `CharGenState::SetTemplate @ 0x005C5A60`; `PlayerFactory.cs:135-138` | +| AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | +| AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(0x10000003, 0xc) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0`. | `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; this row is the marker to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030`; `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) | ## 4. Temporary stopgap (TS) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index b5ef1fdb..da1db90a 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -230,7 +230,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 | — | | | | +| CC3 | IMPLEMENTED 2026-08-15 (unreviewed — Opus dual-lens review owed per campaign process) | `(HEAD — see git log)` | — | `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 / 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. On Ok: appends the identity to the roster by REUSING `RuntimeCharacterSelectionState.ApplyRoster` (read current entries via `View.Visit`, append, re-apply — no new roster-mutation primitive) and logs straight in by REUSING the private `EnterSelectedCore` (no second enter route) — `gmCharGenMainUI::Update @ 0x004E8460`'s per-frame name-scan is deliberately not re-implemented since the SAME `0xF643` Ok reply already carries the exact guid/name (an equivalent, not divergent, substitution). `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). Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text), 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), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (30 cases — every Finish gate, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (4 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 roster append + auto-enter + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate). Runtime 1701/0 (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. | | CC4 | — | | | | | CC5 | — | | | | | CC6a | — | | | | diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index 017b24c4..e1902b50 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -378,6 +378,82 @@ public interface IRuntimeAllegianceCommands bool on); } +// ── Character creation (Campaign CC slice CC3, 2026-08-15) ───────────────── + +/// +/// Generation-gated character-creation (CharGenState) outbound actions — +/// lands beside , the +/// same command family shape. +/// +public interface IRuntimeCharacterCreationCommands +{ + RuntimeCommandResult SelectHeritage( + RuntimeGenerationToken expectedGeneration, + uint heritageId); + + RuntimeCommandResult SelectGender( + RuntimeGenerationToken expectedGeneration, + uint genderKey); + + RuntimeCommandResult SelectTemplate( + RuntimeGenerationToken expectedGeneration, + uint templateIndex); + + RuntimeCommandResult SetAttribute( + RuntimeGenerationToken expectedGeneration, + Session.ChargenAttributeId attributeId, + int value); + + RuntimeCommandResult SetAttributeLock( + RuntimeGenerationToken expectedGeneration, + Session.ChargenAttributeId attributeId, + bool locked); + + RuntimeCommandResult TrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId); + + RuntimeCommandResult SpecializeSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId); + + RuntimeCommandResult UntrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId); + + RuntimeCommandResult SetAppearanceIndex( + RuntimeGenerationToken expectedGeneration, + Session.ChargenAppearanceSlot slot, + uint index); + + RuntimeCommandResult SetShade( + RuntimeGenerationToken expectedGeneration, + Session.ChargenShadeSlot slot, + double value); + + RuntimeCommandResult SelectStartArea( + RuntimeGenerationToken expectedGeneration, + int startAreaIndex); + + RuntimeCommandResult SetName( + RuntimeGenerationToken expectedGeneration, + string name); + + RuntimeCommandResult SetSlot( + RuntimeGenerationToken expectedGeneration, + uint slot); + + /// Retail's Finish button (gmCharGenMainUI::DoFinish @ + /// 0x004E9170). On acceptance the request is already on the wire; + /// the Ok/rejection reply arrives asynchronously as a status delta — + /// see . + RuntimeCommandResult Finish( + RuntimeGenerationToken expectedGeneration); + + RuntimeCommandResult AcknowledgeRejection( + RuntimeGenerationToken expectedGeneration); +} + public interface IGameRuntimeCommands { IRuntimeSessionCommands Session { get; } @@ -386,6 +462,10 @@ public interface IGameRuntimeCommands throw new NotSupportedException( "This command adapter does not project character selection."); + IRuntimeCharacterCreationCommands CharacterCreation => + throw new NotSupportedException( + "This command adapter does not project character creation."); + IRuntimeSelectionCommands Selection { get; } IRuntimeCombatCommands Combat { get; } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index 0c6eb5ce..3a9bcd10 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -74,6 +74,8 @@ public sealed class DirectGameRuntimeCommandAdapter public IRuntimeSessionCommands Session => this; public IRuntimeCharacterSelectionCommands CharacterSelection => _runtime.Session; + public IRuntimeCharacterCreationCommands CharacterCreation => + _runtime.Session; public IRuntimeSelectionCommands Selection => this; public IRuntimeCombatCommands Combat => this; public IRuntimeMagicCommands Magic => this; diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index c96c3c5a..e35e79da 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using AcDream.Core.CharGen; using AcDream.Core.Net; using AcDream.Core.Net.Messages; @@ -107,6 +108,22 @@ public interface ILiveSessionLifecycleHost void ApplySelectedCharacter(LiveSessionCharacterSelection selection); void ApplyEnteredWorld(LiveSessionCharacterSelection selection); void DetachSession(WorldSession session); + + /// + /// Campaign CC slice CC3: reported once per successful character + /// creation (the 0xF643 Ok identity), right before the roster is + /// re-reported with the new entry appended and the reused enter-selected + /// path runs — the same spot and + /// already occupy. Default no-op: a host + /// that wants status-stream parity with + /// SessionStatusWriter.CharacterCreated overrides this. + /// + void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) { } + + /// Campaign CC slice CC3: reported once per non-Ok 0xF643 + /// creation response. Default no-op — see . + /// + void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) { } } /// @@ -192,6 +209,15 @@ public interface ILiveSessionOperations session.SendDeleteCharacter(accountName, activeCharacterIndex); void RestoreCharacter(WorldSession session, uint characterId) => session.SendRestoreCharacter(characterId); + /// Campaign CC slice CC3: retail's Proto_UI::SendCharGenResult + /// @ 0x00546A70 outbound send, reached from gmCharGenMainUI::DoFinish. + /// + void CreateCharacter( + WorldSession session, + string accountName, + CharacterCreate.Request request, + ReadOnlySpan skillAdvancementClasses) => + session.SendCharacterCreation(accountName, request, skillAdvancementClasses); void Tick(WorldSession session); void DisposeSession(WorldSession session); } @@ -247,7 +273,8 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations public sealed class LiveSessionController : IDisposable, IRuntimeLiveSessionFramePhase, - IRuntimeCharacterSelectionCommands + IRuntimeCharacterSelectionCommands, + IRuntimeCharacterCreationCommands { private sealed class CharacterSelectionWireBinding : IDisposable { @@ -257,6 +284,7 @@ public sealed class LiveSessionController private readonly Action _restore; private readonly Action _error; private readonly Action _worldName; + private readonly Action _created; public CharacterSelectionWireBinding( WorldSession session, @@ -264,7 +292,8 @@ public sealed class LiveSessionController Action delete, Action restore, Action error, - Action worldName) + Action worldName, + Action created) { _session = session; _roster = roster; @@ -272,11 +301,13 @@ public sealed class LiveSessionController _restore = restore; _error = error; _worldName = worldName; + _created = created; session.CharacterListReceived += roster; session.CharacterDeleteAcknowledged += delete; session.CharacterRestoreReceived += restore; session.CharacterErrorReceived += error; session.ServerNameReceived += worldName; + session.CharacterCreateResponseReceived += created; } public bool IsDisposed => _session is null; @@ -291,6 +322,7 @@ public sealed class LiveSessionController session.CharacterRestoreReceived -= _restore; session.CharacterErrorReceived -= _error; session.ServerNameReceived -= _worldName; + session.CharacterCreateResponseReceived -= _created; } } @@ -397,11 +429,19 @@ public sealed class LiveSessionController public LiveSessionController( ILiveSessionOperations operations, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + ChargenOptions? chargenOptions = null) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); CharacterSelectionState = new RuntimeCharacterSelectionState( timeProvider); + // Campaign CC slice CC3: defaults to ChargenOptions.Empty (no + // heritages configured) — loading the installed DAT's chargen table + // and threading it through is a future App/GameRuntime wiring slice, + // not this one. A caller that never supplies real options simply + // gets an inert chargen surface (every heritage lookup misses). + CharacterCreationState = new RuntimeCharacterCreationState( + chargenOptions ?? ChargenOptions.Empty); } public RuntimeCharacterSelectionState CharacterSelectionState { get; } @@ -409,6 +449,11 @@ public sealed class LiveSessionController public IRuntimeCharacterSelectionView CharacterSelection => CharacterSelectionState.View; + public RuntimeCharacterCreationState CharacterCreationState { get; } + + public IRuntimeCharacterCreationView CharacterCreation => + CharacterCreationState.View; + public WorldSession? CurrentSession { get { lock (_gate) return _scope?.Session; } @@ -684,6 +729,7 @@ public sealed class LiveSessionController ulong generation = ++_generation; RuntimeGenerationToken activeGeneration = new(generation); CharacterSelectionState.Reset(activeGeneration); + CharacterCreationState.Reset(activeGeneration); try { DrainRetiredScope(); @@ -710,6 +756,7 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials); CharacterSelectionState.Begin(activeGeneration); + CharacterCreationState.Begin(activeGeneration); SessionScope? scope = null; try @@ -921,6 +968,14 @@ public sealed class LiveSessionController if (IsCurrent(scope, generation)) CharacterSelectionState.ApplyWorldName(worldName.WorldName); } + }, + created => + { + lock (_gate) + { + if (IsCurrent(scope, generation)) + HandleCharacterCreationResponse(scope, generation, created); + } }); public RuntimeCommandResult Highlight( @@ -1149,6 +1204,350 @@ public sealed class LiveSessionController new RuntimeGenerationToken(_generation), characterId); + // ── Character creation (Campaign CC slice CC3) ───────────────────── + + /// Collects rows + /// into form for + /// 's roster-append + /// composition. + private sealed class RosterCollector(List entries) + : IRuntimeCharacterSelectionVisitor + { + public void Visit(in RuntimeCharacterSelectionEntry character) => + entries.Add(new LiveSessionRosterEntry( + character.CharacterId, + character.Name, + character.SecondsGreyedOut)); + } + + /// + /// Ports the Ok half of Handle_CharGenVerificationResponse @ + /// 0x0055E8B0 case 1 (the PENDING/create branch — + /// CharacterSet::AddIdentity) composed with + /// gmCharGenMainUI::Update @ 0x004E8460's per-frame roster scan + /// (which finds the freshly appended identity by name and calls + /// CPlayerSystem::LogOnCharacter directly): a fresh + /// is disambiguated locally + /// (we already have the exact created guid/name from the SAME reply + /// that triggered the roster append, so there is no need to re-scan for + /// it the way retail's per-frame poll does — an equivalent, not a + /// divergent, substitution). Reuses + /// for the append (there is no single-entry append primitive to + /// duplicate) and for the log-straight-in + /// (no second enter route). A non-Ok reply only needs the state-machine + /// update already performed by + /// — no roster/enter side effects. + /// + private void HandleCharacterCreationResponse( + SessionScope scope, + ulong generation, + CharGenVerificationResponse.Parsed response) + { + CharacterCreationState.ApplyCreationResponse(response); + RuntimeCharacterCreationSnapshot creation = CharacterCreationState.Snapshot; + + if (creation.LastCreated is { } created) + scope.Host.ApplyCharacterCreated(created); + else if (creation.LastRejection is { } rejection) + scope.Host.ApplyCreationFailed(rejection); + + if (creation.LastCreated is not { } identity) + return; + + RuntimeCharacterSelectionSnapshot before = CharacterSelectionState.Snapshot; + var entries = new List(before.RosterCount + 1); + CharacterSelectionState.View.Visit(new RosterCollector(entries)); + entries.Add(new LiveSessionRosterEntry( + identity.Guid, + identity.Name, + SecondsGreyedOut: 0u)); + var report = new LiveSessionRosterReport( + before.AccountName, + before.SlotCount, + entries); + CharacterSelectionState.ApplyRoster(report); + scope.Host.ReportRoster(report); + if (!IsCurrent(scope, generation)) + return; + + if (!CharacterSelectionState.TryHighlight(identity.Guid)) + return; + if (!IsCurrent(scope, generation)) + return; + + // Inline, not through the public Enter() command: we are already + // running inside Tick()'s top-level operation (this handler fires + // synchronously from _operations.Tick's inbound processing), exactly + // the same calling convention StartCore's own inline enter uses. + _ = EnterSelectedCore(); + } + + public RuntimeCommandResult SelectHeritage( + RuntimeGenerationToken expectedGeneration, + uint heritageId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectHeritage(heritageId)); + } + } + + public RuntimeCommandResult SelectGender( + RuntimeGenerationToken expectedGeneration, + uint genderKey) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectGender(genderKey)); + } + } + + public RuntimeCommandResult SelectTemplate( + RuntimeGenerationToken expectedGeneration, + uint templateIndex) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectTemplate(templateIndex)); + } + } + + public RuntimeCommandResult SetAttribute( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + int value) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySetAttribute(attributeId, value)); + } + } + + public RuntimeCommandResult SetAttributeLock( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + bool locked) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySetAttributeLock(attributeId, locked)); + } + } + + public RuntimeCommandResult TrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TryTrainSkill(skillId)); + } + } + + public RuntimeCommandResult SpecializeSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySpecializeSkill(skillId)); + } + } + + public RuntimeCommandResult UntrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TryUntrainSkill(skillId)); + } + } + + public RuntimeCommandResult SetAppearanceIndex( + RuntimeGenerationToken expectedGeneration, + ChargenAppearanceSlot slot, + uint index) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySetAppearanceIndex(slot, index)); + } + } + + public RuntimeCommandResult SetShade( + RuntimeGenerationToken expectedGeneration, + ChargenShadeSlot slot, + double value) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySetShade(slot, value)); + } + } + + public RuntimeCommandResult SelectStartArea( + RuntimeGenerationToken expectedGeneration, + int startAreaIndex) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TrySelectStartArea(startAreaIndex)); + } + } + + public RuntimeCommandResult SetName( + RuntimeGenerationToken expectedGeneration, + string name) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySetName(name ?? string.Empty)); + } + } + + public RuntimeCommandResult SetSlot( + RuntimeGenerationToken expectedGeneration, + uint slot) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult(CharacterCreationState.TrySetSlot(slot)); + } + } + + /// + /// Ports gmCharGenMainUI::DoFinish @ 0x004E9170's send half: the + /// local gates live in ; + /// this method supplies the roster/slot-cap inputs from + /// and, on acceptance, sends the + /// wire request via Proto_UI::SendCharGenResult's port + /// (). A transport + /// failure resets the verification latch the same way an unsolicited + /// Undef/Pending reply does () + /// rather than leaving it stuck Pending forever. + /// + public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + + RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot; + if (!CharacterCreationState.TryBeginFinish( + selection.RosterCount, + selection.SlotCount, + out CharacterCreate.Request request, + out uint[] skillAdvancementClasses, + out _)) + { + return CharacterCreationResult(RuntimeCommandStatus.Rejected); + } + + try + { + _operations.CreateCharacter( + _scope!.Session, + selection.AccountName, + request, + skillAdvancementClasses); + return CharacterCreationResult(RuntimeCommandStatus.Accepted); + } + catch + { + CharacterCreationState.ApplyCreationResponse( + new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.Undef, + null, + null, + null)); + return CharacterCreationResult(RuntimeCommandStatus.Rejected); + } + } + } + + public RuntimeCommandResult AcknowledgeRejection( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryAcknowledgeRejection()); + } + } + + private RuntimeCommandStatus ValidateCharacterCreationCommand( + RuntimeGenerationToken expectedGeneration) + { + RuntimeGenerationToken current = new(_generation); + if (expectedGeneration != current) + return RuntimeCommandStatus.StaleGeneration; + if (_disposed || _disposeRequested || _scope is null || _inWorld) + return RuntimeCommandStatus.Inactive; + return CharacterSelectionState.Snapshot.Lifecycle + == RuntimeCharacterSelectionLifecycle.AwaitingSelection + ? RuntimeCommandStatus.Accepted + : RuntimeCommandStatus.Inactive; + } + + private RuntimeCommandResult CharacterCreationResult(bool accepted) => + CharacterCreationResult( + accepted ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected); + + private RuntimeCommandResult CharacterCreationResult(RuntimeCommandStatus status) => + new(status, new RuntimeGenerationToken(_generation)); + private void StopCore() { // MUST-FIX 1: TS-71's logout-flush half — retail's @@ -1165,6 +1564,7 @@ public sealed class LiveSessionController _inWorld = false; _activeSelection = null; CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation)); + CharacterCreationState.Reset(new RuntimeGenerationToken(_generation)); if (_scope is { } scope) { _scope = null; @@ -1306,6 +1706,7 @@ public sealed class LiveSessionController { StopCore(); CharacterSelectionState.Dispose(); + CharacterCreationState.Dispose(); _disposed = true; } diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs new file mode 100644 index 00000000..70212973 --- /dev/null +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -0,0 +1,1469 @@ +using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Session; + +/// +/// The six retail attribute ids in CharGenState::GetAbsRemainingCredits @ +/// 0x005C3B20's switch order — 1=Strength, 2=Endurance, 3=Quickness, +/// 4=Coordination, 5=Focus, 6=Self. Note the 3/4 swap versus the "natural" +/// str/end/coord/quick reading order; this is retail's own internal id +/// numbering, distinct from the WIRE attribute order (which IS +/// str/end/coord/quick/focus/self — see ). +/// +public enum ChargenAttributeId +{ + Strength = 1, + Endurance = 2, + Quickness = 3, + Coordination = 4, + Focus = 5, + Self = 6, +} + +/// +/// One appearance index slot. Color slots ( etc.) +/// are a documented approximation (register AP-208): retail derives a +/// PER-STYLE color count via CharGenState::StoreColorInformation @ +/// 0x005C44D0 (reading each clothing style's own ClothingTable +/// palette-template list); acdream has no DAT-backed per-style color count in +/// , so every color slot is bounds-checked +/// against the gender's single shared +/// list instead. +/// +public enum ChargenAppearanceSlot +{ + EyesStrip, + NoseStrip, + MouthStrip, + HairStyle, + HairColor, + EyeColor, + HeadgearStyle, + HeadgearColor, + ShirtStyle, + ShirtColor, + TrousersStyle, + TrousersColor, + FootwearStyle, + FootwearColor, +} + +/// One of the six f64 dye-shade fields (retail's skinShade/hairShade/ +/// headgearShade/shirtShade/trousersShade/footwearShade). +public enum ChargenShadeSlot +{ + Skin, + Hair, + Headgear, + Shirt, + Trousers, + Footwear, +} + +public enum RuntimeCharacterCreationDeltaKind +{ + Reset, + StateChanged, + FinishRefused, + FinishSent, + Created, + CreationFailed, + RejectionAcknowledged, +} + +/// +/// The fourteen style/color indices plus the six f64 shades — exactly +/// CharacterCreate.Appearance's field set (Core.Net, CC2), so a +/// snapshot converts to a wire request with a single field-by-field copy. +/// mirrors retail's 0xFFFFFFFF "nothing selected" +/// sentinel for every index field. +/// +public readonly record struct RuntimeCharacterCreationAppearance( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairStyle, + uint HairColor, + uint EyeColor, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade) +{ + public const uint Unset = 0xFFFFFFFFu; + + /// Retail's construction-time shade sentinel: low dword 0, + /// high dword 0xBFF00000 is the IEEE-754 bit pattern for exactly + /// -1.0 (CharGenState::Reset @ 0x005C68A0). + public const double UnsetShade = -1.0; + + public static RuntimeCharacterCreationAppearance Default { get; } = new( + Unset, Unset, Unset, + Unset, Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + UnsetShade, UnsetShade, UnsetShade, + UnsetShade, UnsetShade, UnsetShade); +} + +/// +/// Every retail client-side gate gmCharGenMainUI::DoFinish @ 0x004E9170 +/// evaluates before a Finish click is allowed to reach the wire, plus the +/// campaign's client-side slot cap (risk item 3 — retail's UI, not +/// DoFinish itself, refuses when the roster is already full versus +/// CharacterList.slotCount; ACE never checks this server-side). +/// +public readonly record struct RuntimeCharacterCreationLocalRefusal( + bool NoName, + bool AttributeCreditsUnspent, + bool AlreadyPending, + bool RosterFull) +{ + public bool Any => + NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull; + + public static RuntimeCharacterCreationLocalRefusal None { get; } = default; +} + +/// +/// A completed creation (the 0xF643 Ok identity payload). +/// +public readonly record struct RuntimeCharacterCreationIdentity( + uint Guid, + string Name); + +/// +/// A non-Ok 0xF643 response, mapped to retail's dialog family +/// (Handle_CharGenVerificationResponse @ 0x0055E8B0's per-case dialog +/// dispatch, restated in 's doc +/// comment): → +/// NameReserved, → +/// NameBanned, / +/// → NameDBDown, +/// → +/// NameAdminDenied. / +/// never produce this +/// record — retail treats them as a silent state reset with no dialog (ACE +/// sends Pending for a disabled-Olthoi rejection; this is a genuine +/// retail quirk, not a bug — port as-is). +/// +public readonly record struct RuntimeCharacterCreationRejection( + uint RawCode, + CharGenVerificationResponse.Code Code, + string Reason, + string AttemptedName); + +public readonly record struct RuntimeCharacterCreationSnapshot( + RuntimeGenerationToken Generation, + bool IsActive, + long Revision, + uint HeritageId, + uint GenderKey, + RuntimeCharacterCreationAppearance Appearance, + uint Template, + ChargenAttributeValues Attributes, + uint AttributeLockMask, + uint TotalAttributeCredits, + int RemainingAttributeCredits, + uint TotalSkillCredits, + int RemainingSkillCredits, + string Name, + int StartArea, + uint Slot, + bool VerificationPending, + RuntimeCharacterCreationLocalRefusal LastLocalRefusal, + RuntimeCharacterCreationRejection? LastRejection, + RuntimeCharacterCreationIdentity? LastCreated) +{ + public const uint TemplateUnset = 0xFFFFFFFFu; + + public bool IsAttributeLocked(ChargenAttributeId attributeId) => + (AttributeLockMask & (1u << ((int)attributeId - 1))) != 0u; +} + +public readonly record struct RuntimeCharacterCreationDelta( + RuntimeGenerationToken Generation, + ulong Sequence, + long Revision, + RuntimeCharacterCreationDeltaKind Kind); + +public interface IRuntimeCharacterCreationObserver +{ + void OnCharacterCreationChanged(in RuntimeCharacterCreationDelta delta); +} + +public interface IRuntimeCharacterCreationEventSource +{ + IDisposable Subscribe(IRuntimeCharacterCreationObserver observer); +} + +/// +/// Borrowed view of the one Runtime-owned CharGenState mirror. +/// +public interface IRuntimeCharacterCreationView : IRuntimeCharacterCreationEventSource +{ + RuntimeCharacterCreationSnapshot Snapshot { get; } + + ChargenSkillAdvancementClass GetSkillLevel(uint skillId); + + /// The typed chargen options this state machine was built from + /// (heritages/templates/skill costs/starter areas) — exposed so + /// presentation can enumerate choices without a second copy of CC1's + /// model. + ChargenOptions Options { get; } +} + +/// +/// Sole mutable owner of character-creation (CharGenState) data. Contains no +/// App/UI types and no wire access — the Finish gate produces a ready-to-send +/// plus the 55-slot skill array; sending +/// it and processing the reply is the caller's job (, +/// mirroring exactly how it owns 's +/// wire-touching pair TryBeginRestore/ApplyRestore). +/// +/// +/// Every AC-specific rule here is ported from the named retail decomp: heritage +/// selection (CharGenState::SetHeritageGroup @ 0x005C67A0), template +/// application (CharGenState::ApplyTemplate @ 0x005C5080, committed via +/// SetTemplate @ 0x005C5A60), gender selection (SetGender @ +/// 0x005C64A0), the six attribute setters + credit math (SetStrength.. +/// SetSelf @ 0x005C4660..0x005C48E0, GetAbsRemainingCredits @ +/// 0x005C3B20, BalanceAttributes @ 0x005C3DF0), skill leveling +/// (SetSkillLevel @ 0x005C3C20, the baseline derivation in +/// ResetSkillLevels @ 0x005C43B0), the random starting-area default +/// (RandomizeStartArea @ 0x005C59E0), and the Finish gate +/// (gmCharGenMainUI::DoFinish @ 0x004E9170). +/// +/// +public sealed class RuntimeCharacterCreationState : IDisposable +{ + /// Retail's literal BalanceAttributes iteration order — + /// 1=str, 2=end, 4=coord, 3=quick, 5=focus, 6=self (NOT numeric id + /// order). See CharGenState::BalanceAttributes @ 0x005C3DF0. + /// + private static readonly ChargenAttributeId[] BalanceOrder = + [ + ChargenAttributeId.Strength, + ChargenAttributeId.Endurance, + ChargenAttributeId.Coordination, + ChargenAttributeId.Quickness, + ChargenAttributeId.Focus, + ChargenAttributeId.Self, + ]; + + private sealed class ViewProjection(RuntimeCharacterCreationState owner) + : IRuntimeCharacterCreationView + { + public RuntimeCharacterCreationSnapshot Snapshot => owner.Snapshot; + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + owner.GetSkillLevel(skillId); + + public ChargenOptions Options => owner._options; + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => + owner._events.Subscribe(observer); + } + + private readonly object _gate = new(); + private readonly CharacterCreationEventStream _events = new(); + private readonly ViewProjection _view; + private readonly ChargenOptions _options; + private readonly Random _random; + private RuntimeGenerationToken _generation; + private bool _active; + private long _revision; + private uint _heritageId; + private uint _genderKey; + private RuntimeCharacterCreationAppearance _appearance = + RuntimeCharacterCreationAppearance.Default; + private uint _template = RuntimeCharacterCreationSnapshot.TemplateUnset; + private ChargenAttributeValues _attributes; + private uint _attributeLockMask; + private uint _totalAttributeCredits; + private int _remainingAttributeCredits; + private uint _totalSkillCredits; + private int _remainingSkillCredits; + private readonly ChargenSkillAdvancementSet _skills = new(); + private int _attributeBalanceCursor = 1; + private string _name = string.Empty; + private int _startArea = -1; + private uint _slot; + private bool _verificationPending; + private RuntimeCharacterCreationLocalRefusal _lastLocalRefusal; + private RuntimeCharacterCreationRejection? _lastRejection; + private RuntimeCharacterCreationIdentity? _lastCreated; + private bool _disposed; + + public RuntimeCharacterCreationState( + ChargenOptions options, + Random? random = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _random = random ?? Random.Shared; + _view = new ViewProjection(this); + } + + public IRuntimeCharacterCreationView View => _view; + + public ChargenOptions Options => _options; + + public RuntimeCharacterCreationSnapshot Snapshot + { + get + { + lock (_gate) + { + return new RuntimeCharacterCreationSnapshot( + _generation, + _active, + _revision, + _heritageId, + _genderKey, + _appearance, + _template, + _attributes, + _attributeLockMask, + _totalAttributeCredits, + _remainingAttributeCredits, + _totalSkillCredits, + _remainingSkillCredits, + _name, + _startArea, + _slot, + _verificationPending, + _lastLocalRefusal, + _lastRejection, + _lastCreated); + } + } + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + internal void Begin(RuntimeGenerationToken generation) + { + lock (_gate) + { + ThrowIfDisposed(); + _generation = generation; + _active = true; + ClearSessionState(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.Reset); + } + + internal void Reset(RuntimeGenerationToken generation) + { + lock (_gate) + { + if (_disposed) + return; + _generation = generation; + _active = false; + ClearSessionState(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.Reset); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _active = false; + ClearSessionState(); + _revision++; + } + _events.Dispose(); + } + + // ── Heritage / gender / template ─────────────────────────────────── + + /// + /// Ports CharGenState::SetHeritageGroup @ 0x005C67A0: recomputes + /// the attribute/skill credit budgets from the heritage row, re-applies + /// the currently selected template (a no-op the first time — retail's + /// ApplyTemplate only mutates attributes/skills when + /// template_ != 0xFFFFFFFF), and rolls a fresh default starting + /// area from the heritage's PrimaryStartAreaIndices + /// (RandomizeStartArea @ 0x005C59E0). Deliberately omits the + /// defensive DAT-corruption clamp in ConstrainAllByHeritage @ + /// 0x005C6590 (a sanity ceiling on totalAtrbCredits/ + /// totalSkillCredits that force-resets everything to zero on + /// malformed content) — CC1's installed-DAT gates already prove the + /// installed heritage table never trips it. + /// + internal bool TrySelectHeritage(uint heritageId) + { + if (!_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + return false; + + lock (_gate) + { + if (_disposed || !_active) + return false; + + _heritageId = heritageId; + _totalAttributeCredits = heritage.AttributeCredits; + _totalSkillCredits = heritage.SkillCredits; + _remainingSkillCredits = checked((int)heritage.SkillCredits); + + ApplyTemplateLocked(heritage); + RandomizeStartAreaLocked(heritage); + ConstrainAppearanceByGenderLocked(); + RecomputeRemainingAttributeCreditsLocked(); + // ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive + // re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through + // our own gated skill commands, but kept for parity with a + // heritage switch that leaves stale skill picks over-budget. + if (RecomputeSkillSpendLocked(heritage) < 0) + ResetSkillLevelsLocked(heritage); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Ports CharGenState::SetGender @ 0x005C64A0: clamps + /// every appearance index into the new gender's option-list bounds. The + /// four SetXStyle(this, this->XStyle) re-invocations retail + /// performs afterward only refresh per-style color-count bookkeeping we + /// don't model (register AP-208); they are a no-op once the shared + /// ClothingColors clamp above has already run, so they are + /// intentionally not re-executed here. + internal bool TrySelectGender(uint genderKey) + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage) + || !heritage.GendersByKey.ContainsKey((int)genderKey)) + { + return false; + } + + _genderKey = genderKey; + ConstrainAppearanceByGenderLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// + /// Ports the seven Profession-page buttons, each of which calls + /// CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60 — template + /// index 0 IS "Custom"/the Adventurer row (CC1 finding), not a UI-only + /// bypass. ApplyTemplate force-overrides to template 0 for the + /// two Olthoi heritages regardless of the requested index. + /// + internal bool TrySelectTemplate(uint templateIndex) + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage) + || templateIndex >= (uint)heritage.Templates.Count) + { + return false; + } + + _template = templateIndex; + ApplyTemplateLocked(heritage); + RecomputeRemainingAttributeCreditsLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// + /// Applies the current row's attributes and + /// skills, mirroring CharGenState::ApplyTemplate @ 0x005C5080 + /// exactly: clears every attribute lock, force-selects template 0 for + /// the Olthoi/OlthoiAcid heritages, and — only when heritage+gender are + /// both selected and a template has actually been chosen — copies the + /// row's six attributes verbatim and re-derives the skill array (baseline + /// reset, then the row's Normal skills trained and Primary skills + /// specialized). + /// + private void ApplyTemplateLocked(ChargenHeritageOptions heritage) + { + _attributeLockMask = 0u; + if (_heritageId == (uint)ChargenHeritageGroup.Olthoi + || _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid) + { + _template = 0u; + } + + if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset) + return; + if (_template >= (uint)heritage.Templates.Count) + return; + + ChargenTemplate row = heritage.Templates[(int)_template]; + _attributes = row.Attributes; + + ResetSkillLevelsLocked(heritage); + foreach (uint skillId in row.NormalSkills) + ApplyTemplateSkillEntryLocked(heritage, skillId, ChargenSkillAdvancementClass.Trained); + foreach (uint skillId in row.PrimarySkills) + ApplyTemplateSkillEntryLocked(heritage, skillId, ChargenSkillAdvancementClass.Specialized); + } + + /// + /// Ports the inline refund-then-charge shape both of ApplyTemplate's + /// skill loops use (the Normal loop calls SetSkillLevel directly; + /// the Primary loop, 0x005C5194-0x005C5229, hand-inlines the identical + /// math): refund whatever the skill's CURRENT class costs (if + /// Trained/Specialized), charge the target class's cost, commit only if + /// the recomputed remaining credits stay non-negative. + /// + private void ApplyTemplateSkillEntryLocked( + ChargenHeritageOptions heritage, + uint skillId, + ChargenSkillAdvancementClass targetClass) + { + if (skillId == 0 || skillId >= ChargenSkillAdvancementSet.SlotCount) + return; + + ChargenSkillAdvancementClass previous = _skills[skillId]; + int remaining = _remainingSkillCredits; + if (previous == ChargenSkillAdvancementClass.Trained + && TryGetSkillCost(heritage, skillId, out int prevTrained, out _)) + { + remaining += prevTrained; + } + else if (previous == ChargenSkillAdvancementClass.Specialized + && TryGetSkillCost(heritage, skillId, out _, out int prevSpecialized)) + { + remaining += prevSpecialized; + } + + if (!TryGetSkillCost(heritage, skillId, out int trainedCost, out int specializedCost)) + return; + + int charge = targetClass switch + { + ChargenSkillAdvancementClass.Specialized => specializedCost, + ChargenSkillAdvancementClass.Trained => trainedCost, + _ => 0, + }; + remaining -= charge; + if (remaining < 0) + return; + + _skills[skillId] = targetClass; + _remainingSkillCredits = remaining; + } + + /// + /// Ports CharGenState::ResetSkillLevels @ 0x005C43B0's baseline + /// derivation: resets the credit counter to the full budget, then for + /// every skill id costable in EITHER tier picks the state that costs + /// nothing yet — TrainedCost>0 → Untrained (must be paid for); + /// TrainedCost==0 && SpecializedCost<=0 → Specialized (free and + /// pre-specialized, e.g. an innate skill); TrainedCost==0 && + /// SpecializedCost>0 → Trained (free to train, costs to specialize). A + /// skill uncostable in both tiers is left untouched (stays Inactive on a + /// fresh set). + /// + private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage) + { + _remainingSkillCredits = checked((int)_totalSkillCredits); + if (_heritageId == 0 || _genderKey == 0) + return; + + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (!TryGetSkillCost(heritage, skillId, out int trainedCost, out int specializedCost)) + continue; + + _skills[skillId] = trainedCost > 0 + ? ChargenSkillAdvancementClass.Untrained + : specializedCost <= 0 + ? ChargenSkillAdvancementClass.Specialized + : ChargenSkillAdvancementClass.Trained; + } + } + + /// Two-tier skill cost lookup — the heritage's own list first, + /// the global SkillTable on a miss (ACCharGenData::GetSkillTrainedCost + /// / GetSkillSpecializedCost, CC1's + /// convention). Returns false when a skill id is uncostable in BOTH + /// tiers (retail's -1/-1 case). + private bool TryGetSkillCost( + ChargenHeritageOptions heritage, + uint skillId, + out int trainedCost, + out int specializedCost) + { + if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost) + || _options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out cost)) + { + trainedCost = cost.NormalCost; + specializedCost = cost.PrimaryCost; + return true; + } + trainedCost = 0; + specializedCost = 0; + return false; + } + + /// Full re-derivation of spend across all 55 slots — reuses + /// CC1's already-reviewed rather + /// than duplicating the two-tier walk. + private int RecomputeSkillSpendLocked(ChargenHeritageOptions heritage) => + checked((int)_totalSkillCredits) + - ChargenSkillCreditMath.ComputeSpent( + _skills, + heritage.SkillCostsBySkillId, + _options.GlobalSkillCostsBySkillId); + + /// Ports CharGenState::RandomizeStartArea @ 0x005C59E0: + /// picks a uniformly random entry from the heritage's + /// PrimaryStartAreaIndices (never SecondaryStartAreaIndices) + /// and adopts it as the default starting area, bounds-checked against + /// the shared starter-area list. + private void RandomizeStartAreaLocked(ChargenHeritageOptions heritage) + { + if (heritage.PrimaryStartAreaIndices.Count == 0) + { + _startArea = -1; + return; + } + int candidate = heritage.PrimaryStartAreaIndices[ + _random.Next(heritage.PrimaryStartAreaIndices.Count)]; + _startArea = candidate >= 0 && candidate < _options.StarterAreas.Count + ? candidate + : -1; + } + + // ── Attributes ────────────────────────────────────────────────────── + + /// + /// Ports gmCGProfessionPage::SetAttribValue @ 0x00482890 (the + /// slider-drag entry point) composed with the matching + /// CharGenState::SetXxx(this, value, balance: 1) setter: clamps + /// the requested value into [10,100], further clamps a RAISE to + /// what allows, then + /// redistributes the overspend from the other unlocked attributes via + /// — exactly retail's own + /// slider-drag behavior (always balance=1; template application uses + /// balance=0 and goes through + /// instead). + /// + internal bool TrySetAttribute(ChargenAttributeId attributeId, int requestedValue) + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0) + return false; + + int current = GetAttributeLocked(attributeId); + int clamped = Math.Clamp( + requestedValue, + ChargenAttributeMath.AttributeMin, + ChargenAttributeMath.AttributeMax); + if (clamped > current) + { + int absRemaining = GetAbsRemainingCreditsLocked(attributeId); + if (clamped - current > absRemaining) + clamped = current + absRemaining; + if (clamped < current) + clamped = current; + } + + SetAttributeRawLocked(attributeId, clamped); + BalanceAttributesLocked(attributeId); + RecomputeRemainingAttributeCreditsLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Ports CharGenState::LockAttribute @ 0x005C3BE0. + internal bool TrySetAttributeLock(ChargenAttributeId attributeId, bool locked) + { + lock (_gate) + { + if (_disposed || !_active) + return false; + uint bit = 1u << ((int)attributeId - 1); + _attributeLockMask = locked + ? _attributeLockMask | bit + : _attributeLockMask & ~bit; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Ports CharGenState::GetAbsRemainingCredits @ + /// 0x005C3B20: the budget remaining if every unlocked attribute + /// OTHER than were reset to the + /// floor — the amount a slider drag on that one attribute may still + /// claim. + private int GetAbsRemainingCreditsLocked(ChargenAttributeId queriedAttributeId) + { + int total = checked((int)_totalAttributeCredits); + foreach (ChargenAttributeId id in BalanceOrder) + { + bool useCurrent = IsAttributeLockedLocked(id) || id == queriedAttributeId; + total -= useCurrent + ? GetAttributeLocked(id) + : ChargenAttributeMath.AttributeMin; + } + return total; + } + + /// + /// Ports CharGenState::BalanceAttributes @ 0x005C3DF0: while the + /// six raw attributes sum above the budget, decrement one unlocked, + /// above-floor attribute at a time (never the just-raised one) in + /// retail's fixed round-robin order, starting from a persistent cursor + /// that advances past whichever attribute last absorbed the overspend. + /// The cursor is an instance field rather than retail's true process + /// global — acdream constructs a fresh Runtime per client process the + /// same way retail's own global resets on every client launch, so the + /// observable behavior is identical. + /// + private void BalanceAttributesLocked(ChargenAttributeId excluded) + { + int over = AttributeTotalLocked() - checked((int)_totalAttributeCredits); + if (over <= 0) + return; + + bool started = false; + // Defensive iteration cap — retail's own caller-side clamp + // (TrySetAttribute's pre-clamp against GetAbsRemainingCreditsLocked) + // guarantees termination the same way SetAttribValue's clamp does in + // retail, but a Runtime service must never spin forever on a future + // caller that skips that clamp. + int guard = 6 * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin) + 1; + while (guard-- > 0) + { + foreach (ChargenAttributeId id in BalanceOrder) + { + if (!started) + { + if ((int)_attributeBalanceCursor != (int)id) + continue; + started = true; + } + if (id == excluded) + continue; + + int value = GetAttributeLocked(id); + if (value > ChargenAttributeMath.AttributeMin && !IsAttributeLockedLocked(id)) + { + over -= 1; + SetAttributeRawLocked(id, value - 1); + if (over <= 0) + { + int index = Array.IndexOf(BalanceOrder, id); + _attributeBalanceCursor = (int)(index == BalanceOrder.Length - 1 + ? BalanceOrder[0] + : BalanceOrder[index + 1]); + return; + } + } + } + started = true; + } + } + + private int AttributeTotalLocked() => _attributes.Total; + + private int GetAttributeLocked(ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => _attributes.Strength, + ChargenAttributeId.Endurance => _attributes.Endurance, + ChargenAttributeId.Quickness => _attributes.Quickness, + ChargenAttributeId.Coordination => _attributes.Coordination, + ChargenAttributeId.Focus => _attributes.Focus, + ChargenAttributeId.Self => _attributes.Self, + _ => 0, + }; + + private void SetAttributeRawLocked(ChargenAttributeId id, int value) + { + _attributes = id switch + { + ChargenAttributeId.Strength => _attributes with { Strength = value }, + ChargenAttributeId.Endurance => _attributes with { Endurance = value }, + ChargenAttributeId.Quickness => _attributes with { Quickness = value }, + ChargenAttributeId.Coordination => _attributes with { Coordination = value }, + ChargenAttributeId.Focus => _attributes with { Focus = value }, + ChargenAttributeId.Self => _attributes with { Self = value }, + _ => _attributes, + }; + } + + private bool IsAttributeLockedLocked(ChargenAttributeId id) => + (_attributeLockMask & (1u << ((int)id - 1))) != 0u; + + private void RecomputeRemainingAttributeCreditsLocked() => + _remainingAttributeCredits = + checked((int)_totalAttributeCredits) - AttributeTotalLocked(); + + // ── Skills ────────────────────────────────────────────────────────── + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) + { + lock (_gate) + return _skills[skillId]; + } + + internal bool TryTrainSkill(uint skillId) => + TrySetSkillLevel(skillId, ChargenSkillAdvancementClass.Trained); + + internal bool TrySpecializeSkill(uint skillId) => + TrySetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized); + + internal bool TryUntrainSkill(uint skillId) => + TrySetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained); + + /// + /// Ports CharGenState::SetSkillLevel @ 0x005C3C20: refund the + /// skill's current class cost (if Trained/Specialized), charge the + /// target class's cost, commit only if the recomputed remaining skill + /// credits stay non-negative. Skills uncostable in BOTH tiers are + /// refused outright — the binding fact from CC1's review (R1: retail's + /// own both-miss branch would refund +1 credit; that path is + /// intentionally kept unreachable by never exposing those 16 ids as + /// advanceable, matching retail's own skills listbox, which never lists + /// them). + /// + private bool TrySetSkillLevel(uint skillId, ChargenSkillAdvancementClass targetClass) + { + if (skillId == 0 || skillId >= ChargenSkillAdvancementSet.SlotCount) + return false; + + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage)) + return false; + if (!TryGetSkillCost(heritage, skillId, out int trainedCost, out int specializedCost)) + return false; + + ChargenSkillAdvancementClass previous = _skills[skillId]; + if (previous == targetClass) + return true; + + int remaining = _remainingSkillCredits; + remaining += previous switch + { + ChargenSkillAdvancementClass.Trained => trainedCost, + ChargenSkillAdvancementClass.Specialized => specializedCost, + _ => 0, + }; + remaining -= targetClass switch + { + ChargenSkillAdvancementClass.Trained => trainedCost, + ChargenSkillAdvancementClass.Specialized => specializedCost, + _ => 0, + }; + if (remaining < 0) + return false; + + _skills[skillId] = targetClass; + _remainingSkillCredits = remaining; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + // ── Appearance ────────────────────────────────────────────────────── + + /// + /// Rejects an out-of-range index rather than silently clamping (unlike + /// retail's own gender-switch reclamp, ) + /// — a caller requesting an index outside the CURRENT gender's option + /// list is a caller bug, not a resize this owner should paper over. + /// always + /// succeeds ("no selection" / "no headgear"). + /// + internal bool TrySetAppearanceIndex(ChargenAppearanceSlot slot, uint index) + { + lock (_gate) + { + if (_disposed || !_active + || !TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + { + return false; + } + + if (index != RuntimeCharacterCreationAppearance.Unset) + { + int count = AppearanceSlotCountLocked(slot, gender); + if (index >= (uint)count) + return false; + } + + _appearance = WithAppearanceIndex(_appearance, slot, index); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + internal bool TrySetShade(ChargenShadeSlot slot, double value) + { + double clamped = Math.Clamp(value, 0.0, 1.0); + lock (_gate) + { + if (_disposed || !_active) + return false; + _appearance = WithShade(_appearance, slot, clamped); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + private bool TryGetGenderOptionsLocked( + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChargenGenderOptions? gender) + { + gender = null; + if (_heritageId == 0 || _genderKey == 0) + return false; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage)) + return false; + return heritage.GendersByKey.TryGetValue((int)_genderKey, out gender); + } + + private static int AppearanceSlotCountLocked( + ChargenAppearanceSlot slot, + ChargenGenderOptions gender) => slot switch + { + ChargenAppearanceSlot.EyesStrip => gender.EyeStrips.Count, + ChargenAppearanceSlot.NoseStrip => gender.NoseStrips.Count, + ChargenAppearanceSlot.MouthStrip => gender.MouthStrips.Count, + ChargenAppearanceSlot.HairStyle => gender.HairStyles.Count, + ChargenAppearanceSlot.HairColor => gender.HairColors.Count, + ChargenAppearanceSlot.EyeColor => gender.EyeColors.Count, + ChargenAppearanceSlot.HeadgearStyle => gender.Headgears.Count, + ChargenAppearanceSlot.ShirtStyle => gender.Shirts.Count, + ChargenAppearanceSlot.TrousersStyle => gender.Pants.Count, + ChargenAppearanceSlot.FootwearStyle => gender.Footwear.Count, + // Color slots: shared-list approximation, register AP-208. + ChargenAppearanceSlot.HeadgearColor + or ChargenAppearanceSlot.ShirtColor + or ChargenAppearanceSlot.TrousersColor + or ChargenAppearanceSlot.FootwearColor => gender.ClothingColors.Count, + _ => 0, + }; + + private static RuntimeCharacterCreationAppearance WithAppearanceIndex( + RuntimeCharacterCreationAppearance appearance, + ChargenAppearanceSlot slot, + uint index) => slot switch + { + ChargenAppearanceSlot.EyesStrip => appearance with { EyesStrip = index }, + ChargenAppearanceSlot.NoseStrip => appearance with { NoseStrip = index }, + ChargenAppearanceSlot.MouthStrip => appearance with { MouthStrip = index }, + ChargenAppearanceSlot.HairStyle => appearance with { HairStyle = index }, + ChargenAppearanceSlot.HairColor => appearance with { HairColor = index }, + ChargenAppearanceSlot.EyeColor => appearance with { EyeColor = index }, + ChargenAppearanceSlot.HeadgearStyle => appearance with { HeadgearStyle = index }, + ChargenAppearanceSlot.HeadgearColor => appearance with { HeadgearColor = index }, + ChargenAppearanceSlot.ShirtStyle => appearance with { ShirtStyle = index }, + ChargenAppearanceSlot.ShirtColor => appearance with { ShirtColor = index }, + ChargenAppearanceSlot.TrousersStyle => appearance with { TrousersStyle = index }, + ChargenAppearanceSlot.TrousersColor => appearance with { TrousersColor = index }, + ChargenAppearanceSlot.FootwearStyle => appearance with { FootwearStyle = index }, + ChargenAppearanceSlot.FootwearColor => appearance with { FootwearColor = index }, + _ => appearance, + }; + + private static RuntimeCharacterCreationAppearance WithShade( + RuntimeCharacterCreationAppearance appearance, + ChargenShadeSlot slot, + double value) => slot switch + { + ChargenShadeSlot.Skin => appearance with { SkinShade = value }, + ChargenShadeSlot.Hair => appearance with { HairShade = value }, + ChargenShadeSlot.Headgear => appearance with { HeadgearShade = value }, + ChargenShadeSlot.Shirt => appearance with { ShirtShade = value }, + ChargenShadeSlot.Trousers => appearance with { TrousersShade = value }, + ChargenShadeSlot.Footwear => appearance with { FootwearShade = value }, + _ => appearance, + }; + + /// Ports the non-reset half of CharGenState::ConstrainAllByGender + /// @ 0x005C5B80: with heritage+gender both selected, clamp every + /// index into the new gender's option-list bounds (never below zero — + /// an empty list clamps to the + /// sentinel, matching a count-1 clamp on a zero count wrapping to + /// -1). Without both selected, every field resets to sentinel, matching + /// the function's other branch. + private void ConstrainAppearanceByGenderLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + { + _appearance = RuntimeCharacterCreationAppearance.Default; + return; + } + + RuntimeCharacterCreationAppearance a = _appearance; + a = WithAppearanceIndex(a, ChargenAppearanceSlot.EyesStrip, ClampIndex(a.EyesStrip, gender.EyeStrips.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.NoseStrip, ClampIndex(a.NoseStrip, gender.NoseStrips.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.MouthStrip, ClampIndex(a.MouthStrip, gender.MouthStrips.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HairStyle, ClampIndex(a.HairStyle, gender.HairStyles.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HairColor, ClampIndex(a.HairColor, gender.HairColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.EyeColor, ClampIndex(a.EyeColor, gender.EyeColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HeadgearStyle, ClampIndex(a.HeadgearStyle, gender.Headgears.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.HeadgearColor, ClampIndex(a.HeadgearColor, gender.ClothingColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.ShirtStyle, ClampIndex(a.ShirtStyle, gender.Shirts.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.ShirtColor, ClampIndex(a.ShirtColor, gender.ClothingColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.TrousersStyle, ClampIndex(a.TrousersStyle, gender.Pants.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.TrousersColor, ClampIndex(a.TrousersColor, gender.ClothingColors.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.FootwearStyle, ClampIndex(a.FootwearStyle, gender.Footwear.Count)); + a = WithAppearanceIndex(a, ChargenAppearanceSlot.FootwearColor, ClampIndex(a.FootwearColor, gender.ClothingColors.Count)); + _appearance = a; + } + + private static uint ClampIndex(uint value, int count) + { + if (value == RuntimeCharacterCreationAppearance.Unset) + return value; + return value >= (uint)count + ? unchecked((uint)(count - 1)) + : value; + } + + // ── Town / name / slot ───────────────────────────────────────────── + + /// Ports CharGenState::SetStartArea @ 0x005C4000 — bounds + /// against the shared starter-area list only, no heritage-list + /// restriction (retail's Town page offers whichever indices it wants; + /// this setter accepts any in-range index). + internal bool TrySelectStartArea(int index) + { + lock (_gate) + { + if (_disposed || !_active) + return false; + if (index < 0 || index >= _options.StarterAreas.Count) + return false; + _startArea = index; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Retail's name[33] buffer — 32 usable chars plus a + /// null terminator. Trimming happens at Finish time + /// (DoFinish's own PStringBase::trim), not here — this + /// setter only enforces the hard length cap. + internal bool TrySetName(string name) + { + ArgumentNullException.ThrowIfNull(name); + string bounded = name.Length > 32 ? name[..32] : name; + lock (_gate) + { + if (_disposed || !_active) + return false; + _name = bounded; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// The CharacterSet slot this create targets. Retail resets + /// this to 0xFFFFFFFF on every rejection + /// (Handle_CharGenVerificationResponse's case 3/4/5/6/7) and the + /// decomp does not show which caller assigns a real value before the + /// first Finish — ACE itself never reads the field + /// (PlayerFactory.cs:154, commented out), so 0 is a safe + /// placeholder until a slot-aware caller (CC4/CC7) sets one. + internal bool TrySetSlot(uint slot) + { + lock (_gate) + { + if (_disposed || !_active) + return false; + _slot = slot; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + // ── Finish / response ─────────────────────────────────────────────── + + /// + /// Ports gmCharGenMainUI::DoFinish @ 0x004E9170's complete gate + /// sequence: trim+commit the name (empty → refuse), require + /// remainingAtrbCredits == 0 (retail forces a full attribute + /// spend), require + /// to be false (no double submit), then the campaign's client-side slot + /// cap (risk item 3 — retail's char-select UI, not DoFinish + /// itself, refuses when the roster is already full; ACE never checks + /// this). On success the verification state flips to Pending and this + /// returns the exact wire request the caller must send via + /// — + /// is ALWAYS materialized from + /// , the seam that + /// makes the 55-slot invariant structurally impossible to violate. + /// + internal bool TryBeginFinish( + int rosterCount, + int slotCount, + out CharacterCreate.Request request, + out uint[] skillAdvancementClasses, + out RuntimeCharacterCreationLocalRefusal refusal) + { + request = default; + skillAdvancementClasses = []; + bool accepted; + lock (_gate) + { + if (_disposed || !_active) + { + refusal = RuntimeCharacterCreationLocalRefusal.None; + return false; + } + + string trimmed = _name.Trim(); + _name = trimmed; + + refusal = trimmed.Length == 0 + ? new RuntimeCharacterCreationLocalRefusal( + NoName: true, false, false, false) + : _remainingAttributeCredits > 0 + ? new RuntimeCharacterCreationLocalRefusal( + false, AttributeCreditsUnspent: true, false, false) + : _verificationPending + ? new RuntimeCharacterCreationLocalRefusal( + false, false, AlreadyPending: true, false) + : slotCount > 0 && rosterCount >= slotCount + ? new RuntimeCharacterCreationLocalRefusal( + false, false, false, RosterFull: true) + : RuntimeCharacterCreationLocalRefusal.None; + + _lastLocalRefusal = refusal; + accepted = !refusal.Any; + if (accepted) + { + _verificationPending = true; + request = BuildRequestLocked(); + skillAdvancementClasses = ToSkillArrayLocked(); + } + _revision++; + } + Publish(accepted + ? RuntimeCharacterCreationDeltaKind.FinishSent + : RuntimeCharacterCreationDeltaKind.FinishRefused); + return accepted; + } + + private CharacterCreate.Request BuildRequestLocked() => new( + _heritageId, + _genderKey, + new CharacterCreate.Appearance( + _appearance.EyesStrip, + _appearance.NoseStrip, + _appearance.MouthStrip, + _appearance.HairColor, + _appearance.EyeColor, + _appearance.HairStyle, + _appearance.HeadgearStyle, + _appearance.HeadgearColor, + _appearance.ShirtStyle, + _appearance.ShirtColor, + _appearance.TrousersStyle, + _appearance.TrousersColor, + _appearance.FootwearStyle, + _appearance.FootwearColor, + _appearance.SkinShade, + _appearance.HairShade, + _appearance.HeadgearShade, + _appearance.ShirtShade, + _appearance.TrousersShade, + _appearance.FootwearShade), + _template, + new CharacterCreate.Attributes( + checked((uint)_attributes.Strength), + checked((uint)_attributes.Endurance), + checked((uint)_attributes.Coordination), + checked((uint)_attributes.Quickness), + checked((uint)_attributes.Focus), + checked((uint)_attributes.Self)), + _slot, + // Retail derives classID via DBObj::GetDIDByEnum(0x10000003, 0xc) at + // GetCharGenResult @0x005C4030 — a DAT DID lookup Core has no access + // to. ACE ignores the field entirely (PlayerFactory.cs:154, + // commented out), so 0 is a documented placeholder — register AP-209. + 0u, + _name, + checked((uint)(_startArea < 0 ? 0 : _startArea)), + IsAdmin: false, + IsEnvoy: false); + + private uint[] ToSkillArrayLocked() + { + IReadOnlyList wire = _skills.ToWireClasses(); + var array = new uint[wire.Count]; + for (int i = 0; i < array.Length; i++) + array[i] = wire[i]; + return array; + } + + /// + /// Ports the four rejection dialog mappings + the silent + /// Pending/Undef reset from Handle_CharGenVerificationResponse @ + /// 0x0055E8B0. Idempotent-tolerant to a second, unrequested Ok/reject + /// while nothing is pending (ACE's own double-NameInUse quirk, CC2 + /// review F3) — a call that arrives while + /// is + /// already false is a no-op rather than a second event. + /// + internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response) + { + RuntimeCharacterCreationDeltaKind kind; + lock (_gate) + { + if (_disposed || !_active || !_verificationPending) + return; + + _verificationPending = false; + if (response.IsOk + && response.Guid is { } guid + && response.Name is { } name) + { + _lastCreated = new RuntimeCharacterCreationIdentity(guid, name); + _lastRejection = null; + kind = RuntimeCharacterCreationDeltaKind.Created; + } + else if (response.AsCode is CharGenVerificationResponse.Code.Pending + or CharGenVerificationResponse.Code.Undef) + { + // Silent state reset — retail shows no dialog (ACE sends + // Pending for a disabled-Olthoi rejection; port as-is). + _revision++; + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return; + } + else + { + string reason = response.AsCode.ToString(); + _lastRejection = new RuntimeCharacterCreationRejection( + response.RawCode, + response.AsCode, + reason, + _name); + kind = RuntimeCharacterCreationDeltaKind.CreationFailed; + } + _revision++; + } + Publish(kind); + } + + internal bool TryAcknowledgeRejection() + { + lock (_gate) + { + if (_disposed || _lastRejection is null) + return false; + _lastRejection = null; + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.RejectionAcknowledged); + return true; + } + + // ── Plumbing ──────────────────────────────────────────────────────── + + private void ClearSessionState() + { + _heritageId = 0; + _genderKey = 0; + _appearance = RuntimeCharacterCreationAppearance.Default; + _template = RuntimeCharacterCreationSnapshot.TemplateUnset; + _attributes = default; + _attributeLockMask = 0u; + _totalAttributeCredits = 0u; + _remainingAttributeCredits = 0; + _totalSkillCredits = 0u; + _remainingSkillCredits = 0; + for (uint i = 1; i < ChargenSkillAdvancementSet.SlotCount; i++) + _skills[i] = ChargenSkillAdvancementClass.Inactive; + _name = string.Empty; + _startArea = -1; + _slot = 0u; + _verificationPending = false; + _lastLocalRefusal = RuntimeCharacterCreationLocalRefusal.None; + _lastRejection = null; + _lastCreated = null; + } + + private void Publish(RuntimeCharacterCreationDeltaKind kind) + { + RuntimeGenerationToken generation; + long revision; + lock (_gate) + { + if (_disposed) + return; + generation = _generation; + revision = _revision; + } + _events.Publish(generation, revision, kind); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); +} + +internal sealed class CharacterCreationEventStream : IDisposable +{ + private readonly object _gate = new(); + private readonly List _pending = []; + private IRuntimeCharacterCreationObserver[] _observers = []; + private ulong _sequence; + private bool _dispatching; + private bool _disposed; + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Array.IndexOf(_observers, observer) >= 0) + { + throw new InvalidOperationException( + "The character-creation observer is already subscribed."); + } + var replacement = new IRuntimeCharacterCreationObserver[_observers.Length + 1]; + Array.Copy(_observers, replacement, _observers.Length); + replacement[^1] = observer; + Volatile.Write(ref _observers, replacement); + } + return new Subscription(this, observer); + } + + public void Publish( + RuntimeGenerationToken generation, + long revision, + RuntimeCharacterCreationDeltaKind kind) + { + lock (_gate) + { + if (_disposed) + return; + _pending.Add(new RuntimeCharacterCreationDelta( + generation, + unchecked(++_sequence), + revision, + kind)); + if (_dispatching) + return; + _dispatching = true; + } + + int index = 0; + while (true) + { + RuntimeCharacterCreationDelta delta; + lock (_gate) + { + if (index >= _pending.Count) + { + _pending.Clear(); + _dispatching = false; + return; + } + delta = _pending[index++]; + } + + foreach (IRuntimeCharacterCreationObserver observer in Volatile.Read(ref _observers)) + { + try + { + observer.OnCharacterCreationChanged(in delta); + } + catch (Exception error) + { + Console.Error.WriteLine( + $"runtime: character-creation observer failed: {error.Message}"); + } + } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + _pending.Clear(); + _dispatching = false; + Volatile.Write(ref _observers, []); + } + } + + private void Unsubscribe(IRuntimeCharacterCreationObserver observer) + { + lock (_gate) + { + int index = Array.IndexOf(_observers, observer); + if (index < 0) + return; + var replacement = new IRuntimeCharacterCreationObserver[_observers.Length - 1]; + if (index > 0) + Array.Copy(_observers, 0, replacement, 0, index); + if (index < _observers.Length - 1) + { + Array.Copy( + _observers, + index + 1, + replacement, + index, + _observers.Length - index - 1); + } + Volatile.Write(ref _observers, replacement); + } + } + + private sealed class Subscription( + CharacterCreationEventStream owner, + IRuntimeCharacterCreationObserver observer) + : IDisposable + { + private CharacterCreationEventStream? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer); + } +} diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs new file mode 100644 index 00000000..cb06bb7a --- /dev/null +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs @@ -0,0 +1,206 @@ +using System.Numerics; +using AcDream.Core.CharGen; + +namespace AcDream.Runtime.Tests.CharGen; + +/// +/// A small, hand-authored fixture for +/// tests — NOT installed-DAT data +/// (that is CC1's ChargenTableReaderInstalledDatTests concern). Shapes +/// mirror retail's field semantics closely enough to exercise every gate: +/// two heritages (a normal one and an Olthoi variant that must force template +/// 0), a "Custom"/floor template plus a str-heavy preset, a mix of costed +/// (including two FREE skills exercising every ResetSkillLevels +/// baseline branch) and deliberately uncostable skill ids, and one gender +/// with small appearance-option lists to exercise index bounds. +/// +internal static class RuntimeCharacterCreationStateFixture +{ + public const uint AluvianId = 1u; + public const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi; + public const uint ImpoverishedId = 90u; + public const uint MaleGenderKey = 1u; + + /// str=10 end=10 coord=10 quick=10 focus=10 self=10 — the + /// budget-66 heritage leaves 6 credits unspent after this template, + /// matching retail's "Custom sits at the floor" finding. + public const uint CustomTemplateIndex = 0u; + + /// str=16, everything else at floor — sum 66 (fully spent). + public const uint PresetTemplateIndex = 1u; + + /// Costed only by the heritage's own list — Normal 4 / Primary 12. + public const uint SkillTrainSpecialize = 1u; + + /// The Preset template's Primary (specialized) skill. + public const uint SkillPresetPrimary = 2u; + + /// FREE and pre-specialized by ResetSkillLevels's baseline + /// (NormalCost=0, PrimaryCost<=0). + public const uint SkillFreeSpecialized = 3u; + + /// FREE to train, costs to specialize + /// (NormalCost=0, PrimaryCost>0). + public const uint SkillFreeTrained = 4u; + + /// Uncostable in BOTH tiers — must never be settable (the CC1 + /// R1 binding fact: keep the both-miss refund path unreachable). + public const uint SkillUncostable = 5u; + + /// The Custom template's authored Normal skill. + public const uint SkillCustomNormal = 24u; + + public static ChargenOptions Build() + { + var starterAreas = new List + { + new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]), + new(1, "Yaraq", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]), + }; + + var skillCosts = new Dictionary + { + [SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12), + [SkillPresetPrimary] = new(SkillPresetPrimary, NormalCost: 3, PrimaryCost: 9), + [SkillFreeSpecialized] = new(SkillFreeSpecialized, NormalCost: 0, PrimaryCost: 0), + [SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 5), + [SkillCustomNormal] = new(SkillCustomNormal, NormalCost: 2, PrimaryCost: 6), + }; + + var gender = new ChargenGenderOptions( + GenderKey: (int)MaleGenderKey, + Name: "Male", + Scale: 1u, + SetupId: 0x2000054u, + SoundTableId: 0u, + IconId: 0u, + BasePaletteId: 0u, + SkinPalSetId: 0u, + PhysicsTableId: 0u, + MotionTableId: 0u, + CombatTableId: 0u, + BaseObjDesc: ChargenObjDesc.Empty, + HairColors: [100u, 101u], + HairStyles: + [ + new ChargenHairStyle(1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + new ChargenHairStyle(2u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + ], + EyeColors: [200u, 201u], + EyeStrips: + [ + new ChargenEyeStrip(1u, 2u, ChargenObjDesc.Empty, ChargenObjDesc.Empty), + ], + NoseStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)], + MouthStrips: [new ChargenFaceStrip(1u, ChargenObjDesc.Empty)], + Headgears: [new ChargenGearOption("Cap", 1u, 300u)], + Shirts: [new ChargenGearOption("Shirt", 2u, 301u)], + Pants: [new ChargenGearOption("Pants", 3u, 302u)], + Footwear: [new ChargenGearOption("Boots", 4u, 303u)], + ClothingColors: [400u, 401u, 402u]); + + var aluvianTemplates = new List + { + new( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [SkillCustomNormal], + PrimarySkills: []), + new( + "Preset", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10), + NormalSkills: [SkillTrainSpecialize], + PrimarySkills: [SkillPresetPrimary]), + }; + + var aluvian = new ChargenHeritageOptions( + AluvianId, + "Aluvian", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 66u, + SkillCredits: 50u, + PrimaryStartAreaIndices: [0, 1], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: skillCosts, + Templates: aluvianTemplates, + GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + + var olthoiTemplates = new List + { + new( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + new( + "NeverChosen", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(20, 20, 20, 20, 20, 20), + NormalSkills: [], + PrimarySkills: []), + }; + + var olthoi = new ChargenHeritageOptions( + OlthoiId, + "Olthoi", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 60u, + SkillCredits: 0u, + PrimaryStartAreaIndices: [0], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: new Dictionary(), + Templates: olthoiTemplates, + GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + + // A deliberately impoverished heritage — just enough skill credits + // to train SkillTrainSpecialize but never specialize it — so a + // TrySpecializeSkill affordability refusal is directly testable + // without needing to hand-drain the richer Aluvian budget. + var impoverished = new ChargenHeritageOptions( + ImpoverishedId, + "Impoverished", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 60u, + SkillCredits: 5u, + PrimaryStartAreaIndices: [0], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: new Dictionary + { + [SkillTrainSpecialize] = new(SkillTrainSpecialize, NormalCost: 4, PrimaryCost: 12), + }, + Templates: + [ + new ChargenTemplate( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + ], + GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + + return new ChargenOptions( + starterAreas, + new Dictionary + { + [AluvianId] = aluvian, + [OlthoiId] = olthoi, + [ImpoverishedId] = impoverished, + }, + new Dictionary()); + } +} diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs new file mode 100644 index 00000000..ae80851f --- /dev/null +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -0,0 +1,505 @@ +using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.CharGen; + +/// +/// Campaign CC slice CC3: — +/// retail's CharGenState mirror. Every test cites the retail function +/// it is pinning; see the class's own doc comments for full addresses. +/// +public sealed class RuntimeCharacterCreationStateTests +{ + private static RuntimeCharacterCreationState CreateActive() + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(1234)); + state.Begin(new RuntimeGenerationToken(1)); + return state; + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + [Fact] + public void Begin_StartsWithNoHeritageOrGenderSelected() + { + RuntimeCharacterCreationState state = CreateActive(); + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + + Assert.True(snapshot.IsActive); + Assert.Equal(0u, snapshot.HeritageId); + Assert.Equal(0u, snapshot.GenderKey); + Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); + Assert.Equal(-1, snapshot.StartArea); + Assert.False(snapshot.VerificationPending); + Assert.Equal(string.Empty, snapshot.Name); + } + + [Fact] + public void Reset_ClearsEveryFieldAndDeactivates() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + Assert.True(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey)); + Assert.True(state.TrySetName("Someone")); + + state.Reset(new RuntimeGenerationToken(2)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.False(snapshot.IsActive); + Assert.Equal(0u, snapshot.HeritageId); + Assert.Equal(0u, snapshot.GenderKey); + Assert.Equal(string.Empty, snapshot.Name); + Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); + // Commands are refused once inactive. + Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + } + + // ── Heritage / gender / template ──────────────────────────────────── + + [Fact] + public void TrySelectHeritage_UnknownId_IsRejected() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.False(state.TrySelectHeritage(0xDEADu)); + Assert.Equal(0u, state.Snapshot.HeritageId); + } + + [Fact] + public void TrySelectHeritage_RecomputesBudgetsAndRollsARandomPrimaryStartArea() + { + RuntimeCharacterCreationState state = CreateActive(); + + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, snapshot.HeritageId); + Assert.Equal(66u, snapshot.TotalAttributeCredits); + Assert.Equal(50u, snapshot.TotalSkillCredits); + // No template chosen yet — ApplyTemplate's own guard leaves + // attributes untouched (CharGenState::ApplyTemplate @ 0x005C5080). + Assert.Equal(0, snapshot.Attributes.Total); + Assert.Equal(66, snapshot.RemainingAttributeCredits); + // RandomizeStartArea @ 0x005C59E0 only ever picks from + // PrimaryStartAreaIndices, [0, 1] in the fixture. + Assert.True(snapshot.StartArea is 0 or 1); + } + + [Fact] + public void TrySelectGender_RequiresHeritageFirst() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.False(state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey)); + } + + [Fact] + public void TrySelectGender_UnknownKeyForHeritage_IsRejected() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + Assert.False(state.TrySelectGender(99u)); + } + + [Fact] + public void TrySelectTemplate_Custom_AppliesFloorAttributesAndLeavesCreditsUnspent() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.Equal(0u, snapshot.Template); + Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), snapshot.Attributes); + // 66 budget - 60 floor spend = 6 unspent, matching CC1's "Custom + // sits at the floor" finding. + Assert.Equal(6, snapshot.RemainingAttributeCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillCustomNormal)); + } + + [Fact] + public void TrySelectTemplate_Preset_TrainsNormalAndSpecializesPrimarySkills() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.Equal(new ChargenAttributeValues(16, 10, 10, 10, 10, 10), snapshot.Attributes); + Assert.Equal(0, snapshot.RemainingAttributeCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal( + ChargenSkillAdvancementClass.Specialized, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillPresetPrimary)); + // ResetSkillLevels' baseline (0x005C43B0) still holds for skills the + // template row doesn't mention. + Assert.Equal( + ChargenSkillAdvancementClass.Specialized, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeSpecialized)); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillFreeTrained)); + } + + [Fact] + public void TrySelectTemplate_OlthoiHeritage_AlwaysForcesTemplateZero() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.OlthoiId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + // CharGenState::ApplyTemplate @ 0x005C5080: mHeritageGroup == 0xc + // force-sets template_ = 0 regardless of the requested index. + Assert.True(state.TrySelectTemplate(1u)); + + Assert.Equal(0u, state.Snapshot.Template); + Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes); + } + + // ── Attributes ────────────────────────────────────────────────────── + + [Fact] + public void TrySetAttribute_ClampsToTheFloorAndCeiling() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 5)); + Assert.Equal(10, state.Snapshot.Attributes.Strength); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 999)); + // Clamped further by the abs-remaining-credits check below 100. + Assert.True(state.Snapshot.Attributes.Strength <= ChargenAttributeMath.AttributeMax); + } + + [Fact] + public void TrySetAttribute_RaisingOneAttributeRebalancesAnAboveFloorAttributeDownToTheFloor() + { + // CharGenState::BalanceAttributes @ 0x005C3DF0: starting from the + // Preset template (Strength=16, everyone else at the 10 floor, fully + // spent), raising Endurance consumes the "assumed floor" room + // GetAbsRemainingCredits grants by pretending Strength could drop to + // floor — BalanceAttributes then actually performs that drop. + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16)); + + ChargenAttributeValues attrs = state.Snapshot.Attributes; + Assert.Equal(16, attrs.Endurance); + Assert.Equal(10, attrs.Strength); + Assert.Equal(10, attrs.Coordination); + Assert.Equal(10, attrs.Quickness); + Assert.Equal(10, attrs.Focus); + Assert.Equal(10, attrs.Self); + Assert.Equal(66, attrs.Total); + Assert.Equal(0, state.Snapshot.RemainingAttributeCredits); + } + + [Fact] + public void TrySetAttributeLock_PreventsThatAttributeFromAbsorbingABalance() + { + // CharGenState::LockAttribute @ 0x005C3BE0 + GetAbsRemainingCredits + // @ 0x005C3B20's locked branch: a locked attribute contributes its + // CURRENT value (not the floor) to the abs-remaining computation, so + // no room is assumed available from it. + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Strength, true)); + + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 16)); + + // No room was available (Strength locked at 16, everything else at + // floor, budget already fully spent) — Endurance cannot rise. + Assert.Equal(10, state.Snapshot.Attributes.Endurance); + Assert.Equal(16, state.Snapshot.Attributes.Strength); + } + + // ── Skills ────────────────────────────────────────────────────────── + + [Fact] + public void TrySpecializeSkill_UncostableSkill_IsAlwaysRejected() + { + // Binding fact from the CC1 review (R1): the 16 uncostable skill ids + // must never be settable — retail's own listbox never lists them. + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + + Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); + Assert.False(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); + Assert.False(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillUncostable)); + Assert.Equal( + ChargenSkillAdvancementClass.Inactive, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillUncostable)); + } + + [Fact] + public void TrainThenSpecializeSkill_ChargesExactlyPrimaryCostNotBoth() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + int before = state.Snapshot.RemainingSkillCredits; + + Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(before - 4, state.Snapshot.RemainingSkillCredits); + + Assert.True(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + // PrimaryCost (12) is the TOTAL, not an increment on NormalCost. + Assert.Equal(before - 12, state.Snapshot.RemainingSkillCredits); + + Assert.True(state.TryUntrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(before, state.Snapshot.RemainingSkillCredits); + } + + [Fact] + public void TrySpecializeSkill_InsufficientCredits_IsRejectedAndLeavesStateUnchanged() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); + Assert.Equal(5, state.Snapshot.RemainingSkillCredits); + + // PrimaryCost (12) exceeds the 5-credit budget outright. + Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(5, state.Snapshot.RemainingSkillCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Untrained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + + // NormalCost (4) fits; the SAME skill Specialized still does not. + Assert.True(state.TryTrainSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(1, state.Snapshot.RemainingSkillCredits); + Assert.False(state.TrySpecializeSkill(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + Assert.Equal(1, state.Snapshot.RemainingSkillCredits); + Assert.Equal( + ChargenSkillAdvancementClass.Trained, + state.GetSkillLevel(RuntimeCharacterCreationStateFixture.SkillTrainSpecialize)); + } + + // ── Finish gates ──────────────────────────────────────────────────── + + private static RuntimeCharacterCreationState ReadyToFinishState() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); // fully spent attrs + state.TrySetName("Adventurer"); + return state; + } + + [Fact] + public void TryBeginFinish_EmptyName_IsRefused() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + state.TrySetName(" "); + + bool accepted = state.TryBeginFinish( + rosterCount: 0, + slotCount: 11, + out _, + out _, + out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.NoName); + Assert.False(state.Snapshot.VerificationPending); + } + + [Fact] + public void TryBeginFinish_UnspentAttributeCredits_IsRefused() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent + state.TrySetName("Adventurer"); + + bool accepted = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.AttributeCreditsUnspent); + } + + [Fact] + public void TryBeginFinish_SecondCallWhilePending_IsRefused() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + Assert.True(state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal first)); + Assert.False(first.Any); + + bool second = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(second); + Assert.True(refusal.AlreadyPending); + } + + [Fact] + public void TryBeginFinish_RosterAtSlotCap_IsRefused() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + + bool accepted = state.TryBeginFinish( + rosterCount: 11, + slotCount: 11, + out _, + out _, + out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.RosterFull); + } + + [Fact] + public void TryBeginFinish_Accepted_TrimsNameAndProducesExactly55SkillSlots() + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + state.TrySetName(" Adventurer "); + + bool accepted = state.TryBeginFinish( + rosterCount: 2, + slotCount: 11, + out CharacterCreate.Request request, + out uint[] skillAdvancementClasses, + out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.True(accepted); + Assert.False(refusal.Any); + Assert.True(state.Snapshot.VerificationPending); + Assert.Equal("Adventurer", request.Name); + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, request.Heritage); + Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, request.Gender); + Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, request.Template); + Assert.Equal(16u, request.Attributes.Strength); + Assert.Equal(CharacterCreate.SkillAdvancementClassCount, skillAdvancementClasses.Length); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Specialized, + skillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); + } + + // ── Response handling ─────────────────────────────────────────────── + + private static RuntimeCharacterCreationState PendingState(out uint[] skills) + { + RuntimeCharacterCreationState state = ReadyToFinishState(); + Assert.True(state.TryBeginFinish(0, 11, out _, out skills, out _)); + return state; + } + + [Fact] + public void ApplyCreationResponse_Ok_RecordsCreatedIdentityAndClearsPending() + { + RuntimeCharacterCreationState state = PendingState(out _); + + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.Ok, 0x5000_1234u, "Adventurer", 0u)); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.False(snapshot.VerificationPending); + Assert.Equal( + new RuntimeCharacterCreationIdentity(0x5000_1234u, "Adventurer"), + snapshot.LastCreated); + Assert.Null(snapshot.LastRejection); + } + + [Theory] + [InlineData(CharGenVerificationResponse.Code.NameInUse)] + [InlineData(CharGenVerificationResponse.Code.NameBanned)] + [InlineData(CharGenVerificationResponse.Code.Corrupt)] + [InlineData(CharGenVerificationResponse.Code.DatabaseDown)] + [InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)] + public void ApplyCreationResponse_EachRejectionCode_RecordsTheMappingAndAttemptedName( + CharGenVerificationResponse.Code code) + { + RuntimeCharacterCreationState state = PendingState(out _); + + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)code, null, null, null)); + + Assert.NotNull(state.Snapshot.LastRejection); + RuntimeCharacterCreationRejection rejection = state.Snapshot.LastRejection!.Value; + Assert.Equal(code, rejection.Code); + Assert.Equal(code.ToString(), rejection.Reason); + Assert.Equal("Adventurer", rejection.AttemptedName); + Assert.False(state.Snapshot.VerificationPending); + Assert.Null(state.Snapshot.LastCreated); + } + + [Theory] + [InlineData(CharGenVerificationResponse.Code.Pending)] + [InlineData(CharGenVerificationResponse.Code.Undef)] + public void ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection( + CharGenVerificationResponse.Code code) + { + // ACE sends Pending for a disabled-Olthoi rejection — retail shows + // no dialog. Port as-is. + RuntimeCharacterCreationState state = PendingState(out _); + + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)code, null, null, null)); + + Assert.False(state.Snapshot.VerificationPending); + Assert.Null(state.Snapshot.LastRejection); + Assert.Null(state.Snapshot.LastCreated); + } + + [Fact] + public void ApplyCreationResponse_DuplicateReplyWhileNotPending_IsIgnored() + { + // ACE's own quirk (CharacterHandler.CharacterCreateEx calls + // IsCharacterNameAvailable TWICE, producing two NameInUse replies + // for one rejected create): the second reply must be a no-op, not a + // second rejection event/state change. + RuntimeCharacterCreationState state = PendingState(out _); + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.NameInUse, null, null, null)); + Assert.NotNull(state.Snapshot.LastRejection); + + // Acknowledge to clear, then feed a SECOND unsolicited reply — must + // stay cleared (idempotent-tolerant, no crash, no new rejection). + Assert.True(state.TryAcknowledgeRejection()); + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.NameInUse, null, null, null)); + + Assert.Null(state.Snapshot.LastRejection); + } + + [Fact] + public void TryAcknowledgeRejection_ClearsTheSurfacedRejection() + { + RuntimeCharacterCreationState state = PendingState(out _); + state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( + (uint)CharGenVerificationResponse.Code.NameBanned, null, null, null)); + Assert.NotNull(state.Snapshot.LastRejection); + + Assert.True(state.TryAcknowledgeRejection()); + + Assert.Null(state.Snapshot.LastRejection); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs new file mode 100644 index 00000000..6b5eb3d5 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -0,0 +1,371 @@ +using System.Buffers.Binary; +using System.Net; +using System.Reflection; +using System.Text; +using AcDream.Core.CharGen; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Packets; +using AcDream.Runtime; +using AcDream.Runtime.Session; +using AcDream.Runtime.Tests.CharGen; + +namespace AcDream.Runtime.Tests.Session; + +/// +/// Campaign CC slice CC3: 's +/// character-creation integration — the wire send (via a REAL +/// + GameMessageCapture, the same seam +/// WorldSessionCharacterCreationTests uses in Core.Net) and the +/// Ok-response round trip (roster append reusing +/// , the reused +/// EnterSelectedCore log-straight-in, and the new +/// / +/// hooks) via a +/// real inbound 0xF643 packet through WorldSession.ProcessDatagram +/// (reflection — the same private test seam Core.Net's own creation tests +/// use). +/// +public sealed class LiveSessionControllerCharacterCreationTests +{ + private sealed class TestTransport : IWorldSessionTransport + { + public void Send(ReadOnlySpan datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + public int Receive(Span destination, TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return -1; + } + public ValueTask ReceiveAsync( + Memory destination, + CancellationToken cancellationToken) => + throw new OperationCanceledException(cancellationToken); + public void Dispose() { } + } + + private sealed class TestOperations : ILiveSessionOperations + { + public List Sessions { get; } = []; + public int EnterWorldCount { get; private set; } + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession(endpoint, new TestTransport()); + Sessions.Add(session); + return session; + } + + public void Connect(WorldSession session, string user, string password) { } + + public void StartCharacterSelectionReceive(WorldSession session) { } + + public CharacterList.Parsed? GetCharacters(WorldSession session) => new( + 0u, + [new CharacterList.Character(0x50000001u, "Existing", 0u)], + [], + SlotCount: 11, + AccountName: "testaccount", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) => + EnterWorldCount++; + + public void Tick(WorldSession session) { } + + public void DisposeSession(WorldSession session) { } + } + + private sealed class TestHost : ILiveSessionLifecycleHost + { + public List Rosters { get; } = []; + public List EnteredWorld { get; } = []; + public List Created { get; } = []; + public List Failed { get; } = []; + + public LiveSessionBinding BindSession(WorldSession session) => + new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { }); + public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { } + public void ReportConnecting(string host, int port, string user) { } + public void ReportConnected() { } + public void ReportRoster(LiveSessionRosterReport roster) => Rosters.Add(roster); + public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } + public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => + EnteredWorld.Add(selection); + public void DetachSession(WorldSession session) { } + public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => + Created.Add(identity); + public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => + Failed.Add(rejection); + } + + private static LiveSessionConnectOptions LiveOptions() => new( + Enabled: true, + "127.0.0.1", + 9000, + "testaccount", + "password", + Character: null, + Probe: false, + AwaitCharacterSelection: true); + + private static (LiveSessionController Controller, TestOperations Operations, TestHost Host, RuntimeGenerationToken Generation) + StartAwaitingSelection() + { + var operations = new TestOperations(); + var host = new TestHost(); + var controller = new LiveSessionController( + operations, + timeProvider: null, + RuntimeCharacterCreationStateFixture.Build()); + + LiveSessionStartResult result = controller.Start(LiveOptions(), host); + Assert.Equal(LiveSessionStartStatus.AwaitingCharacterSelection, result.Status); + + return (controller, operations, host, controller.Generation); + } + + private static void BuildReadyCharacter(LiveSessionController controller, RuntimeGenerationToken generation) + { + Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); + Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); + Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted); + Assert.True(controller.SetName(generation, "NewChar").Accepted); + } + + [Fact] + public void Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName() + { + (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + + WorldSession session = operations.Sessions[0]; + byte[]? captured = null; + GameMessageGroup? capturedGroup = null; + session.GameMessageCapture = (body, group) => + { + captured = body; + capturedGroup = group; + }; + + RuntimeCommandResult result = controller.Finish(generation); + + Assert.True(result.Accepted); + Assert.NotNull(captured); + Assert.Equal(GameMessageGroup.LoginQueue, capturedGroup); + + CapturedCreateRequest decoded = DecodeCreateRequest(captured!); + Assert.Equal("testaccount", decoded.AccountName); + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, decoded.Heritage); + Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, decoded.Gender); + Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, decoded.Template); + Assert.Equal(16u, decoded.Strength); + Assert.Equal("NewChar", decoded.Name); + Assert.Equal((uint)CharacterCreate.SkillAdvancementClassCount, decoded.NumSkills); + Assert.Equal(CharacterCreate.SkillAdvancementClassCount, decoded.SkillAdvancementClasses.Length); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Specialized, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); + } + + [Fact] + public void Finish_ThenOkResponse_AppendsToRosterAndLogsStraightIn() + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); + + Assert.Single(host.Created); + Assert.Equal(0x50001234u, host.Created[0].Guid); + Assert.Equal("NewChar", host.Created[0].Name); + Assert.Empty(host.Failed); + + // The roster report following the Ok reply has BOTH the pre-existing + // character and the newly created one. + LiveSessionRosterReport lastReport = host.Rosters[^1]; + Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar"); + Assert.Contains(lastReport.Entries, e => e.Id == 0x50000001u); + + // gmCharGenMainUI::Update @ 0x004E8460's log-straight-in, reused via + // EnterSelectedCore — the controller is now in-world as the new + // character, no second selection/EnterWorld call needed. + Assert.True(controller.IsInWorld); + Assert.Equal(1, operations.EnterWorldCount); + Assert.Single(host.EnteredWorld); + Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); + } + + [Fact] + public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection() + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty)); + + Assert.Single(host.Failed); + Assert.Equal(CharGenVerificationResponse.Code.NameInUse, host.Failed[0].Code); + Assert.Equal("NewChar", host.Failed[0].AttemptedName); + Assert.Empty(host.Created); + Assert.False(controller.IsInWorld); + Assert.Equal(0, operations.EnterWorldCount); + Assert.Empty(host.EnteredWorld); + // No roster append on a rejection. + Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); + } + + [Fact] + public void Finish_RefusedLocallyWithUnspentAttributeCredits_NeverTouchesTheWire() + { + (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); + Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); + Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted); + Assert.True(controller.SetName(generation, "NewChar").Accepted); + WorldSession session = operations.Sessions[0]; + bool sent = false; + session.GameMessageCapture = (_, _) => sent = true; + + RuntimeCommandResult result = controller.Finish(generation); + + Assert.False(result.Accepted); + Assert.False(sent); + } + + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) + { + MethodInfo method = typeof(WorldSession).GetMethod( + "ProcessDatagram", + BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(session, [new ReadOnlyMemory(datagram), null, true]); + } + + private static byte[] BuildResponseBody(uint code, uint guid, string name) + { + var w = new PacketWriter(); + w.WriteUInt32(CharGenVerificationResponse.ResponseOpcode); + w.WriteUInt32(code); + if (code == (uint)CharGenVerificationResponse.Code.Ok) + { + w.WriteUInt32(guid); + w.WriteString16L(name); + w.WriteUInt32(0u); + } + return w.ToArray(); + } + + private static byte[] BuildResponsePacket(uint code, uint guid, string name) + { + byte[] message = BuildResponseBody(code, guid, name); + var fragments = new byte[MessageFragmentHeader.Size + message.Length]; + GameMessageFragment.WriteSingleFragment(fragments.AsSpan(), fragmentSequence: 1u, GameMessageGroup.UIQueue, message); + return PacketCodec.Encode( + new PacketHeader { Sequence = 1u, Flags = PacketHeaderFlags.BlobFragments }, + fragments, + outboundIsaac: null); + } + + private readonly record struct CapturedCreateRequest( + string AccountName, + uint Heritage, + uint Gender, + uint Template, + uint Strength, + string Name, + uint NumSkills, + uint[] SkillAdvancementClasses); + + /// Manual mirror of 's + /// exact field order — see that class's doc comment for the full + /// layout. + private static CapturedCreateRequest DecodeCreateRequest(ReadOnlySpan body) + { + int pos = 0; + uint opcode = ReadU32(body, ref pos); + Assert.Equal(CharacterCreate.Opcode, opcode); + string accountName = ReadString16L(body, ref pos); + uint constant = ReadU32(body, ref pos); + Assert.Equal(1u, constant); + uint heritage = ReadU32(body, ref pos); + uint gender = ReadU32(body, ref pos); + _ = ReadU32(body, ref pos); // eyesStrip + _ = ReadU32(body, ref pos); // noseStrip + _ = ReadU32(body, ref pos); // mouthStrip + _ = ReadU32(body, ref pos); // hairColor + _ = ReadU32(body, ref pos); // eyeColor + _ = ReadU32(body, ref pos); // hairStyle + _ = ReadU32(body, ref pos); // headgearStyle + _ = ReadU32(body, ref pos); // headgearColor + _ = ReadU32(body, ref pos); // shirtStyle + _ = ReadU32(body, ref pos); // shirtColor + _ = ReadU32(body, ref pos); // trousersStyle + _ = ReadU32(body, ref pos); // trousersColor + _ = ReadU32(body, ref pos); // footwearStyle + _ = ReadU32(body, ref pos); // footwearColor + for (int i = 0; i < 6; i++) + _ = ReadF64(body, ref pos); // six shades + uint template = ReadU32(body, ref pos); + uint strength = ReadU32(body, ref pos); + _ = ReadU32(body, ref pos); // endurance + _ = ReadU32(body, ref pos); // coordination + _ = ReadU32(body, ref pos); // quickness + _ = ReadU32(body, ref pos); // focus + _ = ReadU32(body, ref pos); // self + _ = ReadU32(body, ref pos); // slot + _ = ReadU32(body, ref pos); // classId + uint numSkills = ReadU32(body, ref pos); + var skills = new uint[numSkills]; + for (int i = 0; i < numSkills; i++) + skills[i] = ReadU32(body, ref pos); + string name = ReadString16L(body, ref pos); + // startArea, isAdmin, isEnvoy, checksum follow — not needed here. + return new CapturedCreateRequest( + accountName, heritage, gender, template, strength, name, numSkills, skills); + } + + private static uint ReadU32(ReadOnlySpan body, ref int pos) + { + uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + return value; + } + + private static double ReadF64(ReadOnlySpan body, ref int pos) + { + double value = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos)); + pos += 8; + return value; + } + + private static string ReadString16L(ReadOnlySpan body, ref int pos) + { + ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); + string value = Encoding.ASCII.GetString(body.Slice(pos + 2, len)); + int recordSize = 2 + len; + int padding = (4 - (recordSize & 3)) & 3; + pos += recordSize + padding; + return value; + } +} From 397ccd62cdcab08566343be2d710581afa119bd3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 15:10:43 +0200 Subject: [PATCH 085/138] =?UTF-8?q?fix(runtime):=20Campaign=20CC=20slice?= =?UTF-8?q?=20CC3=20review-fix=20round=20=E2=80=94=20F1-F16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of CC3's RuntimeCharacterCreationState passed on retail fidelity but failed the controller integration: the post-create log-straight-in indexed the CACHED wire CharacterList, which ACE never resends after a create (it only appends server-side and replies Ok) — with zero pre-existing characters this throws, with N it can silently enter the WRONG character. The same stale-index problem corrupted every pre-existing character's delete slot on roster re-sort. Fixes all four blocking findings plus a credit-gate correctness bug (retail warns and lets the user confirm through unspent credits; it does not force a full spend) and eight lower-severity findings from the same review round. F1 (blocking): WorldSession gained a guid-based EnterWorld(uint,string, TimeSpan?) overload sharing EnterWorldCore with the index-based one; ILiveSessionOperations gained a default EnterWorldByGuid method. LiveSessionController factored EnterSelectedCore/the new EnterCreatedCharacterCore through a shared EnterHighlightedCore so the post-create enter sends by the exact guid the 0xF643 Ok reply carried, never by a roster index. F2 (blocking): RuntimeCharacterSelectionState gained a real AppendCreatedCharacter primitive that preserves every existing entry's ActiveIndex (a wire contract — SendDeleteCharacter sends it as the CharacterSet slot) and assigns the new entry's from the pre-create wire roster count, instead of round-tripping the post-create roster through ApplyRoster's name-sort-and-renumber. F3 (blocking): retail's DoFinish(this, arg2) gate is "arg2 != 0 && remainingAtrbCredits > 0" — the ordinary click warns and refuses, but the warning dialog's own confirm re-invokes DoFinish(this, 0), which sends anyway with credits unspent (ACE accepts this). TryBeginFinish/Finish gained a confirmedUnspentCredits parameter; the plan doc's "retail FORCES full spend" line is corrected in the same commit. F4 (blocking): a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to TemplateUnset, matching ConstrainAllByHeritage's clamp. F5/F9/F10: three register-row/doc citation corrections (AP-207's real FitTemplateToCharacter call sites — a fourth one the original filing also missed; the Slot field's real retail assignment source; AP-209's classID branch table for Olthoi/OlthoiAcid). F6: ApplyCreationResponse no longer publishes from inside the owner lock. F7: two new tests pin BalanceAttributes' persistent donor cursor (successive-overspend advance, Self-to-Strength wrap). F8: ResetSkillLevels' doc corrected to retail's real both-costs->=0 gate. F11: the integration test fixture captures guid-based enter calls and uses two pre-existing characters whose wire order differs from alphabetical order, so the roster 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 (no retail DoFinish-layer counterpart). F13: narrowed Finish's bare catch to InvalidOperationException/SocketException and bound _scope to a local. F15: RandomizeStartAreaLocked leaves the start area unchanged on an empty list instead of forcing -1, matching retail. Runtime 1706/0 (was 1701), Core.Net unchanged at 994/0, full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 7 +- .../2026-08-15-character-creation-campaign.md | 20 ++- src/AcDream.Core.Net/WorldSession.cs | 50 +++++- src/AcDream.Runtime/GameRuntimeCommands.cs | 12 +- .../Session/LiveSessionController.cs | 135 +++++++++++++--- .../Session/RuntimeCharacterCreationState.cs | 131 +++++++++++---- .../Session/RuntimeCharacterSelectionState.cs | 47 ++++++ .../RuntimeCharacterCreationStateTests.cs | 150 ++++++++++++++++++ ...SessionControllerCharacterCreationTests.cs | 94 ++++++++++- 9 files changed, 578 insertions(+), 68 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4c19a3af..913e52fc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -197,7 +197,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 146 active rows (AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 147 active rows (AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -383,10 +383,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-190 | **Filed 2026-08-10 (Campaign CH slice CH6c — window opacity + transparency setting; retires AP-40). AMENDED 2026-08-10 at the CH6c review-fix round: reworded (2), added (3)/(4).** Four divergences from retail's focus-driven window opacity, all decomp-verified (`docs/research/2026-08-09-chat-retail-window-shell.md` §3). (1) SCOPE: retail's `ChatInterface::SetOpacity`/`SetDefaultOpacity`/`SetActiveOpacity` only ever run on `ChatInterface`-derived windows (the main chat window + the four floaties) — every other retail window (vitals, toolbar, inventory, ...) has no opacity fade at all. acdream's `RetailWindowOpacityController` subscribes to `RetailWindowManager.WindowRegistered` and applies the SAME focus-driven fade to every window the manager ever registers, so the one Settings → Chat tab transparency slider pair affects the whole retained UI. (2) DEFAULT VALUE — REWORDED at the review-fix round: retail's shipped defaults are PER WINDOW CLASS — the base `ChatInterface` ctor (`0x004F4550`) sets DefaultOpacity=0.5/ActiveOpacity=1.0, but `gmMainChatUI`'s own ctor (`0x004CD0F0`, called after the base ctor) overrides DefaultOpacity to 1.0 (the main window is ALWAYS fully opaque in both states); `gmFloatyChatUI::Create` (`0x004CE2C0`) calls the base ctor directly with no override, so only the four floating windows keep 0.5/1.0. acdream originally shipped the base ChatInterface value (0.5/1.0) as ONE shared global default applied to EVERY registered window — combined with (1)'s scope extension this faded the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity out of the box, including several windows that can never take keyboard focus at all and so were PERMANENTLY stuck at 0.5. Fixed at the review round to `gmMainChatUI`'s 1.0/1.0 override as the shared default instead: this reduces the remaining divergence to acdream's four floating chat windows shipping OPAQUE where retail's floaties ship 0.5-while-idle — user-settable via the same Settings → Chat opacity slider pair, so it is now a default-VALUE divergence only, not a missing mechanism. (3) EASING (new, filed at the review-fix round): retail's `ChatInterface::ListenToGlobalMessage @0x004F3840` — armed on the focus element-messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` at `0x004F5275` via `UIListener::RegisterForGlobalMessage(this, 3)` — eases the live opacity toward its target by 5% of the target-delta per tick, unregistering from the global tick once within FP-epsilon of the target. acdream's `RetailWindowOpacityController.Apply` snaps to the target opacity immediately on every focus-change event; porting the per-tick lerp needs a UI frame-tick hook the controller does not have today, so it is deferred rather than implemented this round. (4) FOCUS PREDICATE (new, filed at the review-fix round): retail's `ChatInterface::IsTextEntryFocused @0x004F30A0` tests specifically whether `GetFocusDescendant(rootElement) == this->m_chatEntry` — the chat ENTRY FIELD, not the window generally. acdream's `RetailWindowHandle.DescendantFocusChanged` fires whenever ANY focusable descendant of the window gains focus, a strictly broader predicate for any window with more than one focusable child. The linked active>=default invariant itself (`SetDefaultOpacity`/`SetActiveOpacity`'s mutual-correction bodies) IS ported exactly — `ChatOpacityLink` in `AcDream.UI.Abstractions`. | `src/AcDream.App/UI/RetailWindowOpacityController.cs`; `src/AcDream.App/UI/RetailWindowManager.cs` (`WindowRegistered`); `src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` (`DefaultOpacity`/`ActiveOpacity`) | Extending the fade to every window is the shape the user's requested "transparency setting" actually wants (a general UI preference, not a chat-only one); shipping the shared default at 1.0 keeps the out-of-box render retail-identical for the 11 non-chat windows AND the main chat window (the windows retail keeps opaque, several of which can never take focus at all), while the Settings → Chat transparency slider remains fully user-settable for anyone who wants the four floaties' retail translucence back. (3) and (4) are both presentation-only refinements — the fade direction and the linked-invariant math stay retail-exact, only the transition curve (snap vs. 5%-per-tick ease) and the focus predicate's granularity (any descendant vs. the text-entry specifically) diverge — so recording them without implementing the frame-tick hook (3) or narrowing the focus event (4) is the correct scope for a review-fix round rather than opening new implementation work | A user who compares acdream's default install against retail side-by-side now sees the 11 non-chat windows AND the main chat window matching (opaque); only the four floating chat windows still diverge (opaque vs. retail's 50%-while-idle) until the slider is dragged. (3) is visible as the opacity change happening in a single frame instead of retail's ~20-tick fade — low severity, since the START and END states are both retail-exact, only the transition is instant instead of eased. (4) is visible on any window with more than one distinct focusable descendant (e.g. a settings panel with several controls): acdream stays at ActiveOpacity while ANY of them holds focus, where retail would already have faded back to DefaultOpacity once focus left the specific text-entry element — for single-focusable-child windows (most of the retained UI today) the two predicates coincide and there is no observable difference | `ChatInterface::ChatInterface @0x004F4550`; `gmMainChatUI::gmMainChatUI @0x004CD0F0`; `gmFloatyChatUI::Create @0x004CE2C0`; `ChatInterface::SetDefaultOpacity @0x004F3BC0`/`SetActiveOpacity @0x004F3C40`; `ChatInterface::ListenToGlobalMessage @0x004F3840`; `ChatInterface::IsTextEntryFocused @0x004F30A0`; global-message arming switch @0x004F5275 (`UIListener::RegisterForGlobalMessage(this, 3)` on element messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E`) | | AP-191 | **Filed 2026-08-10 (Campaign CH round 4, user-gate items 1+2 — retail two-plane glyph outline + authored SpewBox/chat text style, `docs/research/2026-08-10-retail-ui-text-style.md`).** The chat transcript's authored BASE STYLE (`0x10000372` in layout `0x2100003F`) carries a `0x1C`/`0x1D` pair alongside its `0x1A`/`0x1B` — `0x1D` (`TagFontColor[]`) is confirmed authored `ARGB(255,0,178,0)` (green), and `0x1C` is UNVERIFIED but most likely `TagFontDID` by symmetry with `0x1D` (both are pull-based, no `OnSetAttribute` case, unlike `0x1A`/`0x1B`/`0x21`/`0x22` which this round's commit DOES import). Retail's `AppendTextWithFont` selects a font/colour PAIR per appended run via `SetFontDIDNum`/`SetFontColorNum`, so a message's `[General]`-style channel tag can render in a distinct colour/font from the rest of the line — a capability `UiText.Line` does not have (one `Color` per whole line, no sub-line run concept). Landing this needs a per-run tag boundary threaded from `ChatTranscriptRenderer.BuildLines` through `UiText`'s line model into `UiRenderContext.DrawStringDat`, deliberately out of this round's scope (Fix 5 only changed the DEFAULT/uncolored-run seed, not the run model). `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`BuildLines`); `src/AcDream.App/UI/UiText.cs` (`Line`) | The default-fill fix (this same commit) is the higher-value, lower-risk half of retail's text-style gap for the transcript; a per-run tag concept is a larger structural change (touches the line model every transcript consumer reads) better landed as its own reviewed slice than folded into a text-style bugfix commit | Retail's `[General]`/channel-name tag prefix on a chat line renders the SAME colour as the rest of the line in acdream instead of green, and any authored tag-specific font goes unused — cosmetic only, the message text itself is unaffected | `UIElement_Text::AppendTextWithFont @0x00469de0`; `UIElement_Text::SetFontColorHelper @0x00466ac0`; `docs/research/2026-08-10-retail-ui-text-style.md` §2.3/§2.6 | | AP-192 | **Filed 2026-08-10 (Campaign CH round-5 polish, review item S2 — non-UiText outline paths).** Authored glyph outline `0x21`/outline color `0x22` now reach every text-bearing retained widget (`UiText`, `UiButton`, `UiDatElement`, `UiField`, `UiMeter`, `UiMenu`, `UiCatalogSlot` — the last two settable-only, having no authored build path), seeded ONCE from the element's effective-default state via `ElementReader.ApplyCanonicalLegacyProjection`'s `TryGetEffectiveProperty` (DirectState-then-effective-default rule). Retail instead re-resolves text properties on every UI STATE CHANGE — a button entering state `0x3` whose StateDesc authors `0x21=true` gains the outline for the duration of that state. The authored data hits this today: the dialog panel's two buttons (`0x2100003C` elements `0x17`/`0x19`), the character panel button `0x10000535`, and the combat panel button `0x100000B2` each author `0x21=true` in state `0x3` ONLY (DefaultStateId=1 → no outline at effective-default; `0x100000B2` also authors DirectState `0x21=true`, which the canonical rule DOES honor). The same seed-once shape already governs `UiText` (its `ApplyDatState` re-resolves `0x1B` FontColor per state but not `0x21`/`0x22`). `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (BuildButton/BuildCheckbox/BuildMeter/BuildText + the editable-field branch); `src/AcDream.App/UI/UiText.cs` (`ApplyDatState`) | Seed-once from the canonical effective state is strictly closer to retail than the pre-round-5 any-state first-wins scan (which lit those state-`0x3` outlines PERMANENTLY); the widening this row rides in on makes every ALWAYS-outlined authored element (DirectState/default-state authors) render retail-correct, and per-state re-resolution needs a property-application pass on the existing `TrySetRetailState` path — a reviewed slice of its own, not a polish-commit fold-in | A button that retail outlines only in a specific UI state (the four state-`0x3` authors above — state 3 is a hover/highlight-class state) never shows that transient outline in acdream; conversely nothing over-renders, since the effective-default resolution correctly yields outline-off for those elements | `UIElement_Text::SetOutline @0x0046a81c` (`m_bitField & 0x10`); `UIElement_Text::DrawSelf @0x00467aa0` (two-pass outline+fill); LayoutDesc fixtures `dialogs_2100003C.json` (`0x17`/`0x19`), `character_2100002E.json` (`0x10000535`), `combat_21000073.json` (`0x100000B2`) | -| AP-207 | **Filed 2026-08-15 at Campaign CC slice CC3 (character-creation state machine).** Retail re-detects the closest-matching Profession template on every attribute-slider edit (`CharGenState::FitTemplateToCharacter @ 0x005C6130`, called from `gmCGProfessionPage::SetAttribValue @ 0x00482890` after every raise/lower), auto-flipping `template_` to whichever preset the current attribute+skill spread scores closest to (or to `0xFFFFFFFF`/"no match" when nothing fits within tolerance) via an FPU-heavy weighted-distance heuristic (`TEMPLATE_WEIGHT_ATTRIBUTES`/`_TRAINED_SKILLS`/`_SPECIALIZED_SKILLS`). Several of the function's float operations are literally unrecoverable in the named decomp (`/* unimplemented {fild/fidiv/fmul/fadd ...} */` markers Binary Ninja could not translate), consistent with this project's existing x87-blocked precedent. `RuntimeCharacterCreationState` never re-derives `Template` from attribute/skill edits — it only changes via an explicit `SelectTemplate` command, matching `SetTemplate @ 0x005C5A60`'s own commit path. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TrySetAttribute`, `TrySetSkillLevel` — neither calls a `FitTemplateToCharacter` port) | ACE's `PlayerFactory.CreatePlayer` only reads `TemplateOption` for the character's display title/name text (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138`) — it never re-validates attributes/skills against the named template, so a stale `Template` value has no server-side consequence; porting an FPU-unrecoverable heuristic for a value ACE ignores is not a good trade. | A free-editing user who drifts away from their chosen template's exact spread keeps seeing that template's name/button highlighted instead of retail's live re-detection (which might silently flip to a different preset name, or to "Custom"); this is presentation-only until CC4/CC5 build the Profession page's button highlight. | `CharGenState::FitTemplateToCharacter @ 0x005C6130`; `gmCGProfessionPage::SetAttribValue @ 0x00482890`; `CharGenState::SetTemplate @ 0x005C5A60`; `PlayerFactory.cs:135-138` | +| AP-207 | **Filed 2026-08-15 at Campaign CC slice CC3 (character-creation state machine). ANCHOR CORRECTED at the CC3 review-fix round (F5) — the original citation (`gmCGProfessionPage::SetAttribValue @ 0x00482890`) does not call `FitTemplateToCharacter`; it only writes the raw attribute via `SetStrength`/`SetEndurance`/etc. then calls `gmCGProfessionPage::UpdateAttributeValues`, which is one of the real call sites below.** Retail re-detects the closest-matching Profession template on every attribute-slider edit (`CharGenState::FitTemplateToCharacter @ 0x005C6130`, called from FOUR real sites: `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450` (call at `0x004827F4`), `gmCGProfessionPage::Update @ 0x00482830` (call at `0x00482840`), `gmCGProfessionPage::UpdateToDefaultAttributes @ 0x00482860` (call at `0x00482875` — a fourth site the original filing also missed), and `gmCGSummaryPage::Update @ 0x0047BAA0` (call at `0x0047BB63`)), auto-flipping `template_` to whichever preset the current attribute+skill spread scores closest to (or to `0xFFFFFFFF`/"no match" when nothing fits within tolerance) via an FPU-heavy weighted-distance heuristic (`TEMPLATE_WEIGHT_ATTRIBUTES`/`_TRAINED_SKILLS`/`_SPECIALIZED_SKILLS`). Several of the function's float operations are literally unrecoverable in the named decomp (`/* unimplemented {fild/fidiv/fmul/fadd ...} */` markers Binary Ninja could not translate), consistent with this project's existing x87-blocked precedent. `RuntimeCharacterCreationState` never re-derives `Template` from attribute/skill edits — it only changes via an explicit `SelectTemplate` command, matching `SetTemplate @ 0x005C5A60`'s own commit path. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TrySetAttribute`, `TrySetSkillLevel` — neither calls a `FitTemplateToCharacter` port) | ACE's `PlayerFactory.CreatePlayer` only reads `TemplateOption` for the character's display title/name text (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138`) — it never re-validates attributes/skills against the named template, so a stale `Template` value has no server-side consequence; porting an FPU-unrecoverable heuristic for a value ACE ignores is not a good trade. | A free-editing user who drifts away from their chosen template's exact spread keeps seeing that template's name/button highlighted instead of retail's live re-detection (which might silently flip to a different preset name, or to "Custom"); this is presentation-only until CC4/CC5 build the Profession page's button highlight. | `CharGenState::FitTemplateToCharacter @ 0x005C6130`; `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450`; `gmCGProfessionPage::Update @ 0x00482830`; `gmCGProfessionPage::UpdateToDefaultAttributes @ 0x00482860`; `gmCGSummaryPage::Update @ 0x0047BAA0`; `CharGenState::SetTemplate @ 0x005C5A60`; `PlayerFactory.cs:135-138` | | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | -| AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(0x10000003, 0xc) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0`. | `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; this row is the marker to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030`; `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | +| 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-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) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index da1db90a..f65b5179 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -52,11 +52,19 @@ verificationState. Writers per page in the recon (SetHeritageGroup recomputes budgets + ApplyTemplate + RandomizeStartArea; SetGender reapplies clothing and UpdateTrueFacePal). -**Finish** (`DoFinish@236864`): trim+set name → empty name → -`ID_CharGen_NoNameWarning`, abort; `remainingAtrbCredits > 0` → credit -warning, abort (retail FORCES full spend — ACE does not; we port the client -gate); verification state must be UNDEF (no double submit) → set PENDING → -`Proto_UI::SendCharGenResult@0x00546A70`. +**Finish** (`DoFinish(this, arg2)@236864`): trim+set name → empty name → +`ID_CharGen_NoNameWarning`, abort. **CORRECTED at the CC3 review-fix round +(F3) — the original line here (`remainingAtrbCredits > 0` → abort, "retail +FORCES full spend") was WRONG; retail does NOT force a full spend.** The +real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary +Finish-button click passes `arg2 = 1` (@0x004E9579), and on unspent +credits shows `MakeCreditWarningDialog` and returns WITHOUT sending +(@0x004E91F2-0x004E9210) — but that dialog's own confirm handler +re-invokes `DoFinish(this, 0)` (@0x004E98BB), which SKIPS the credit check +entirely (`arg2 == 0`) and sends with the credits still unspent. ACE +accepts this — `ValidateAttributeCredits` only rejects a total that +EXCEEDS the max, never an under-spend. Then: verification state must be +UNDEF (no double submit) → set PENDING → `Proto_UI::SendCharGenResult@0x00546A70`. **Wire 0xF656** (`ACCharGenResult::CG_Pack@0x005C7200`, byte-identical to ACE's `CharacterCreateInfo.Unpack`): account String16L FIRST (outside the @@ -230,7 +238,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 | IMPLEMENTED 2026-08-15 (unreviewed — Opus dual-lens review owed per campaign process) | `(HEAD — see git log)` | — | `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 / 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. On Ok: appends the identity to the roster by REUSING `RuntimeCharacterSelectionState.ApplyRoster` (read current entries via `View.Visit`, append, re-apply — no new roster-mutation primitive) and logs straight in by REUSING the private `EnterSelectedCore` (no second enter route) — `gmCharGenMainUI::Update @ 0x004E8460`'s per-frame name-scan is deliberately not re-implemented since the SAME `0xF643` Ok reply already carries the exact guid/name (an equivalent, not divergent, substitution). `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). Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text), 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), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (30 cases — every Finish gate, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (4 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 roster append + auto-enter + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate). Runtime 1701/0 (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. | +| CC3 | FIX ROUND COMPLETE 2026-08-15 (Opus dual-lens review found blocking findings F1-F4 on the controller integration, retail fidelity PASS; fix round addressed F1-F16; narrow re-review owed) | `9a84230c` (implementation), this fix round (see git log — sha unknowable pre-commit) | Blocking findings fixed (see fix-round summary below); narrow re-review owed | `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 | — | | | | | CC5 | — | | | | | CC6a | — | | | | diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 0881b2cb..e135a6cf 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1149,12 +1149,54 @@ public sealed class WorldSession : IDisposable { if (Characters is null || Characters.Characters.Count == 0) throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList"); - var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); EnterWorldSelection selection = SelectCharacterForEnterWorld( Characters, characterIndex); - CharacterList.Character chosen = selection.Character; - _activeCharacterId = chosen.Id; + EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout); + } + + /// + /// Send CharacterEnterWorldRequest and CharacterEnterWorld for the exact + /// (guid, accountName) identity the caller supplies, bypassing the + /// cached roster entirely. Campaign CC slice + /// CC3 review-fix round (F1): the index-based overload above assumes + /// refers to a slot in + /// — true for ordinary character-select entry, + /// but FALSE immediately after a character create. ACE never resends + /// post-create (it only appends server-side + /// and replies with the 0xF643 Ok identity — + /// references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:170-172), + /// so entering the newly created character by a re-derived index can + /// throw (zero pre-existing characters) or silently enter the WRONG + /// character (N pre-existing characters, since the caller's display + /// order need not match the wire order). Retail's own + /// CPlayerSystem::LogOnCharacter(gid) is itself guid-based, so + /// this is a more direct port of the same entry point — not a + /// deviation from retail — for the one caller (enter-straight-in after + /// create) that has an exact identity in hand and no reliable index. + /// + /// + /// Retail's own fallback when the freshly created name never appears in + /// its per-frame roster poll (gmCharGenMainUI::Update @ + /// 0x004E8460) bounces the UI back to character management + /// (QueueUIMode(0x1000000a) @ 0x004E85D7). acdream has no + /// analogous fallback here because this entry point is driven directly + /// by the identity carried on the SAME reply that confirms the create + /// succeeded — there is no polling step that could fail to find the + /// name, so there is nothing for a fallback to catch. + /// + /// + public void EnterWorld(uint characterGuid, string accountName, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(accountName); + byte[] enterWorldBody = CharacterEnterWorld.BuildEnterWorldBody(characterGuid, accountName); + EnterWorldCore(characterGuid, enterWorldBody, timeout); + } + + private void EnterWorldCore(uint characterGuid, byte[] enterWorldBody, TimeSpan? timeout) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); + _activeCharacterId = characterGuid; Transition(State.EnteringWorld); SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody()); @@ -1220,7 +1262,7 @@ public sealed class WorldSession : IDisposable // CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account // populated by CharacterSet::UnPack, not the spelling supplied to the // login form. ACE validates this canonical account value. - SendGameMessage(selection.EnterWorldBody); + SendGameMessage(enterWorldBody); // LoginComplete is emitted by the host only after the accepted local // Create has completed its canonical first placement. Sending it at diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index e1902b50..1b34e5b8 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -446,9 +446,17 @@ public interface IRuntimeCharacterCreationCommands /// Retail's Finish button (gmCharGenMainUI::DoFinish @ /// 0x004E9170). On acceptance the request is already on the wire; /// the Ok/rejection reply arrives asynchronously as a status delta — - /// see . + /// see . + /// is retail's arg2 == 0 + /// — the credit-warning dialog's own confirm click — skipping the + /// unspent-attribute-credits gate; the ordinary caller passes + /// false (retail's arg2 = 1 button click), which shows + /// that warning instead of sending when credits remain. See + /// 's + /// doc comment for the full citation. RuntimeCommandResult Finish( - RuntimeGenerationToken expectedGeneration); + RuntimeGenerationToken expectedGeneration, + bool confirmUnspentCredits = false); RuntimeCommandResult AcknowledgeRejection( RuntimeGenerationToken expectedGeneration); diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index e35e79da..c169fce8 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -202,6 +202,21 @@ public interface ILiveSessionOperations void StartCharacterSelectionReceive(WorldSession session) => session.StartCharacterSelectionReceive(); void EnterWorld(WorldSession session, int activeCharacterIndex); + + /// + /// Campaign CC slice CC3 review-fix round (F1): mirrors + /// but enters by the exact + /// guid the 0xF643 Ok reply carried, bypassing the (by-design + /// post-create-stale) cached roster — see + /// 's doc + /// comment for the full retail citation. + /// + void EnterWorldByGuid( + WorldSession session, + uint characterGuid, + string accountName) => + session.EnterWorld(characterGuid, accountName); + void DeleteCharacter( WorldSession session, string accountName, @@ -1118,7 +1133,33 @@ public sealed class LiveSessionController } } - private RuntimeCommandResult EnterSelectedCore() + private RuntimeCommandResult EnterSelectedCore() => + EnterHighlightedCore(static (operations, session, character, _) => + operations.EnterWorld(session, character.ActiveIndex)); + + /// + /// Campaign CC slice CC3 review-fix round (F1): the log-straight-in + /// half of — identical + /// transaction shape to , but sends the + /// EnterWorld wire request by the exact created guid rather than by a + /// roster index (see ). + /// + private RuntimeCommandResult EnterCreatedCharacterCore( + RuntimeCharacterCreationIdentity identity) => + EnterHighlightedCore((operations, session, _, accountName) => + operations.EnterWorldByGuid(session, identity.Guid, accountName)); + + /// + /// Shared transaction for "the highlighted character is about to enter + /// the world": select it, send the caller-supplied EnterWorld wire + /// request, activate commands, and publish the entered-world state. + /// is the only thing that differs + /// between the ordinary index-based selection flow + /// () and the post-create guid-based flow + /// (). + /// + private RuntimeCommandResult EnterHighlightedCore( + Action sendEnterWorld) { SessionScope scope = _scope!; ulong generation = _generation; @@ -1141,7 +1182,7 @@ public sealed class LiveSessionController if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); - _operations.EnterWorld(scope.Session, character.ActiveIndex); + sendEnterWorld(_operations, scope.Session, character, snapshot.AccountName); if (!IsCurrent(scope, generation)) return CharacterSelectionResult(RuntimeCommandStatus.Inactive); @@ -1231,11 +1272,36 @@ public sealed class LiveSessionController /// (we already have the exact created guid/name from the SAME reply /// that triggered the roster append, so there is no need to re-scan for /// it the way retail's per-frame poll does — an equivalent, not a - /// divergent, substitution). Reuses - /// for the append (there is no single-entry append primitive to - /// duplicate) and for the log-straight-in - /// (no second enter route). A non-Ok reply only needs the state-machine - /// update already performed by + /// divergent, substitution). + /// + /// + /// Campaign CC slice CC3 review-fix round (F1/F2): the roster append no + /// longer round-trips through + /// — that re-derives EVERY entry's ActiveIndex from display + /// (name-sorted) order, and ActiveIndex is a wire contract + /// (SendDeleteCharacter sends it as the CharacterSet slot; ACE + /// indexes session.Characters[(int)characterSlot] — + /// references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297), + /// so a full re-sort would silently retarget every PRE-EXISTING + /// character's delete slot to its alphabetical rank. Instead + /// + /// preserves every existing entry's ActiveIndex and assigns the + /// new entry's from the wire count BEFORE this create (ACE appends to + /// session.Characters, so the new character's slot equals that + /// pre-create count, 0-based) — read from the cached wire list + /// (), the SAME source + /// the ordinary index-enter path reads, not the sorted display mirror. + /// The subsequent log-straight-in also no longer goes through + /// 's roster-index EnterWorld send — that + /// cached wire list is BY DESIGN stale for the just-created character + /// (ACE never resends CharacterList post-create), so it uses + /// 's guid-based send instead + /// (see that method's and 's + /// doc comments). + /// + /// + /// A non-Ok reply only needs the state-machine update already performed + /// by /// — no roster/enter side effects. /// private void HandleCharacterCreationResponse( @@ -1265,7 +1331,19 @@ public sealed class LiveSessionController before.AccountName, before.SlotCount, entries); - CharacterSelectionState.ApplyRoster(report); + + // F2: the new character's wire slot is the pre-create count of the + // cached wire roster (ACE appends; that cached list is stale for + // THIS character by design, but its COUNT is still exactly the + // 0-based slot ACE assigned). Falls back to the display roster + // count only if the cached wire list is unexpectedly unavailable. + int wireIndex = + _operations.GetCharacters(scope.Session)?.Characters.Count + ?? before.RosterCount; + CharacterSelectionState.AppendCreatedCharacter( + identity.Guid, + identity.Name, + wireIndex); scope.Host.ReportRoster(report); if (!IsCurrent(scope, generation)) return; @@ -1279,7 +1357,7 @@ public sealed class LiveSessionController // running inside Tick()'s top-level operation (this handler fires // synchronously from _operations.Tick's inbound processing), exactly // the same calling convention StartCore's own inline enter uses. - _ = EnterSelectedCore(); + _ = EnterCreatedCharacterCore(identity); } public RuntimeCommandResult SelectHeritage( @@ -1463,17 +1541,25 @@ public sealed class LiveSessionController } /// - /// Ports gmCharGenMainUI::DoFinish @ 0x004E9170's send half: the - /// local gates live in ; - /// this method supplies the roster/slot-cap inputs from + /// Ports gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170's + /// send half: the local gates live in + /// ; this + /// method supplies the roster/slot-cap inputs from /// and, on acceptance, sends the /// wire request via Proto_UI::SendCharGenResult's port - /// (). A transport - /// failure resets the verification latch the same way an unsolicited - /// Undef/Pending reply does () + /// (). + /// is retail's arg2 == 0 + /// case — see 's + /// doc comment for the full credit-warning-dialog citation; the ordinary + /// caller passes false (retail's arg2 = 1 button click). A + /// transport failure resets the verification latch the same way an + /// unsolicited Undef/Pending reply does + /// () /// rather than leaving it stuck Pending forever. /// - public RuntimeCommandResult Finish(RuntimeGenerationToken expectedGeneration) + public RuntimeCommandResult Finish( + RuntimeGenerationToken expectedGeneration, + bool confirmUnspentCredits = false) { lock (_gate) { @@ -1481,13 +1567,15 @@ public sealed class LiveSessionController if (gate != RuntimeCommandStatus.Accepted) return CharacterCreationResult(gate); + SessionScope scope = _scope!; RuntimeCharacterSelectionSnapshot selection = CharacterSelectionState.Snapshot; if (!CharacterCreationState.TryBeginFinish( selection.RosterCount, selection.SlotCount, out CharacterCreate.Request request, out uint[] skillAdvancementClasses, - out _)) + out _, + confirmUnspentCredits)) { return CharacterCreationResult(RuntimeCommandStatus.Rejected); } @@ -1495,13 +1583,22 @@ public sealed class LiveSessionController try { _operations.CreateCharacter( - _scope!.Session, + scope.Session, selection.AccountName, request, skillAdvancementClasses); return CharacterCreationResult(RuntimeCommandStatus.Accepted); } - catch + // F13: narrowed to what SendCharacterCreation's send path + // actually throws — WorldSession.SendGameMessage's own + // InvalidOperationException (transport not yet negotiated) and + // whatever the underlying UDP send raises (SocketException). + // Anything else is a genuine bug, not a transport hiccup, and + // should propagate rather than being silently swallowed into a + // rejection. + catch (Exception error) when ( + error is InvalidOperationException + or System.Net.Sockets.SocketException) { CharacterCreationState.ApplyCreationResponse( new CharGenVerificationResponse.Parsed( diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index 70212973..f38ee9ee 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -506,6 +506,20 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// row's six attributes verbatim and re-derives the skill array (baseline /// reset, then the row's Normal skills trained and Primary skills /// specialized). + /// + /// + /// Campaign CC slice CC3 review-fix round (F4): when the currently + /// selected index is out of range for THIS + /// heritage's template list (a heritage switch left a stale index from + /// a previous, richer heritage), this clears it to + /// instead + /// of merely returning — mirroring the clamp + /// CharGenState::ConstrainAllByHeritage @ 0x005C65CC performs + /// right after ApplyTemplate in SetHeritageGroup + /// (if (template_ >= count) template_ = 0xffffffff;). Without + /// this, the stale out-of-range index would survive unchanged and reach + /// the wire via . + /// /// private void ApplyTemplateLocked(ChargenHeritageOptions heritage) { @@ -519,7 +533,10 @@ public sealed class RuntimeCharacterCreationState : IDisposable if (_heritageId == 0 || _genderKey == 0 || _template == RuntimeCharacterCreationSnapshot.TemplateUnset) return; if (_template >= (uint)heritage.Templates.Count) + { + _template = RuntimeCharacterCreationSnapshot.TemplateUnset; return; + } ChargenTemplate row = heritage.Templates[(int)_template]; _attributes = row.Attributes; @@ -580,13 +597,34 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// /// Ports CharGenState::ResetSkillLevels @ 0x005C43B0's baseline /// derivation: resets the credit counter to the full budget, then for - /// every skill id costable in EITHER tier picks the state that costs - /// nothing yet — TrainedCost>0 → Untrained (must be paid for); - /// TrainedCost==0 && SpecializedCost<=0 → Specialized (free and - /// pre-specialized, e.g. an innate skill); TrainedCost==0 && - /// SpecializedCost>0 → Trained (free to train, costs to specialize). A - /// skill uncostable in both tiers is left untouched (stays Inactive on a - /// fresh set). + /// every skill id classifies TrainedCost>0 → Untrained (must be paid + /// for); TrainedCost==0 && SpecializedCost<=0 → Specialized + /// (free and pre-specialized, e.g. an innate skill); TrainedCost==0 + /// && SpecializedCost>0 → Trained (free to train, costs to + /// specialize). A skill uncostable in both tiers is left untouched + /// (stays Inactive on a fresh set). + /// + /// + /// Campaign CC slice CC3 review-fix round (F8): retail's real gate at + /// 0x005C4487 is if (trainedCost >= 0 && + /// specializedCost >= 0) — BOTH tiers non-negative, not "either + /// tier costable" as this comment previously said. This port's + /// instead gates on DICTIONARY PRESENCE + /// (found in the heritage's own list or the global SkillTable), which + /// is equivalent ONLY because of a CC1-established, installed-DAT-gated + /// invariant: costs are stored VERBATIM (never filtered), and for every + /// entry the reader ever produces, both NormalCost and + /// PrimaryCost are non-negative + /// (ChargenTableReaderInstalledDatTests's + /// NormalCost >= 0/PrimaryCost >= 0 assertions); a + /// skill uncostable in either tier is simply ABSENT from both the + /// heritage and global dictionaries in the installed DAT (retail's -1 + /// case), not present with a negative value. If that installed-DAT + /// shape ever changed (a partially-negative entry, one tier <0 and + /// the other >=0), this dictionary-presence gate would diverge from + /// retail's real per-value check — the regression test above is what + /// would catch that drift. + /// /// private void ResetSkillLevelsLocked(ChargenHeritageOptions heritage) { @@ -644,14 +682,17 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// picks a uniformly random entry from the heritage's /// PrimaryStartAreaIndices (never SecondaryStartAreaIndices) /// and adopts it as the default starting area, bounds-checked against - /// the shared starter-area list. + /// the shared starter-area list. Campaign CC slice CC3 review-fix round + /// (F15): retail (0x005C5A0A, the if (var_9c > 0) + /// guard) touches startArea ONLY inside that branch — an empty + /// PrimaryStartAreaIndices leaves the field COMPLETELY + /// UNTOUCHED, not reset to -1. Unreachable through the installed + /// DAT (every heritage ships a non-empty primary list), but aligned + /// here for exactness. private void RandomizeStartAreaLocked(ChargenHeritageOptions heritage) { if (heritage.PrimaryStartAreaIndices.Count == 0) - { - _startArea = -1; return; - } int candidate = heritage.PrimaryStartAreaIndices[ _random.Next(heritage.PrimaryStartAreaIndices.Count)]; _startArea = candidate >= 0 && candidate < _options.StarterAreas.Count @@ -1097,13 +1138,22 @@ public sealed class RuntimeCharacterCreationState : IDisposable return true; } - /// The CharacterSet slot this create targets. Retail resets - /// this to 0xFFFFFFFF on every rejection - /// (Handle_CharGenVerificationResponse's case 3/4/5/6/7) and the - /// decomp does not show which caller assigns a real value before the - /// first Finish — ACE itself never reads the field - /// (PlayerFactory.cs:154, commented out), so 0 is a safe - /// placeholder until a slot-aware caller (CC4/CC7) sets one. + /// The CharacterSet slot this create targets. Retail DOES + /// assign it — but only as a side effect of char-select, not chargen: + /// gmCharacterManagementUI::SelectCharacter @ 0x004EC160 calls + /// CharGenState::SetSlot(CharacterSet::GetSlot(...)) @ + /// 0x004EC22A with the slot of whichever EXISTING character the + /// player last clicked in the character-select list, reset to + /// 0xFFFFFFFF on entering chargen (0x004EC074, + /// 0x0055EA9A) and again on every rejection + /// (Handle_CharGenVerificationResponse's case 3/4/5/6/7). The + /// value retail actually sends on Finish is therefore semantically + /// STALE — the last-selected PRE-EXISTING character's own slot, not + /// anything about the character being created — and ACE never reads + /// the field regardless (PlayerFactory.cs:154, commented out). + /// 0 is a safe placeholder for the same reason it is safe in + /// retail: it is exactly as meaningless to ACE as retail's own stale + /// value. internal bool TrySetSlot(uint slot) { lock (_gate) @@ -1120,10 +1170,25 @@ public sealed class RuntimeCharacterCreationState : IDisposable // ── Finish / response ─────────────────────────────────────────────── /// - /// Ports gmCharGenMainUI::DoFinish @ 0x004E9170's complete gate - /// sequence: trim+commit the name (empty → refuse), require - /// remainingAtrbCredits == 0 (retail forces a full attribute - /// spend), require + /// Ports gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170's + /// complete gate sequence: trim+commit the name (empty → refuse), then + /// — ONLY when is false, + /// mirroring retail's arg2 != 0 half of + /// arg2 != 0 && remainingAtrbCredits > 0 — require + /// remainingAtrbCredits == 0. Retail does NOT force a full + /// attribute spend: the ordinary Finish-button click passes + /// arg2 = 1 (0x004E9579) and, on unspent credits, shows a + /// WARNING dialog and returns WITHOUT sending + /// (MakeCreditWarningDialog @ 0x004E91F6); that dialog's own + /// confirm handler re-invokes DoFinish(this, 0) + /// (0x004E98BB), which skips the credit check entirely and + /// sends with the credits still unspent. ACE accepts this + /// (ValidateAttributeCredits only rejects a total that EXCEEDS + /// the max, never an under-spend). + /// is retail's arg2 == 0 case: pass true only from the + /// warning dialog's own confirm path (CC4) or an equivalent headless + /// caller that has already decided to proceed with unspent credits. + /// Then requires /// to be false (no double submit), then the campaign's client-side slot /// cap (risk item 3 — retail's char-select UI, not DoFinish /// itself, refuses when the roster is already full; ACE never checks @@ -1139,7 +1204,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable int slotCount, out CharacterCreate.Request request, out uint[] skillAdvancementClasses, - out RuntimeCharacterCreationLocalRefusal refusal) + out RuntimeCharacterCreationLocalRefusal refusal, + bool confirmedUnspentCredits = false) { request = default; skillAdvancementClasses = []; @@ -1158,7 +1224,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable refusal = trimmed.Length == 0 ? new RuntimeCharacterCreationLocalRefusal( NoName: true, false, false, false) - : _remainingAttributeCredits > 0 + : !confirmedUnspentCredits && _remainingAttributeCredits > 0 ? new RuntimeCharacterCreationLocalRefusal( false, AttributeCreditsUnspent: true, false, false) : _verificationPending @@ -1245,6 +1311,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// review F3) — a call that arrives while /// is /// already false is a no-op rather than a second event. + /// + /// + /// Campaign CC slice CC3 review-fix round (F6): every branch below only + /// SETS kind inside lock (_gate); the single + /// call happens once, after the lock releases — + /// matching every other public method in this class. The Pending/Undef + /// branch previously published from inside the lock (harmless on its + /// own — 's own lock (_gate) is reentrant on + /// the same thread — but inconsistent with the rest of the class and a + /// lock-ordering risk once an observer callback reaches back into + /// caller-held locks, e.g. LiveSessionController._gate, while + /// still inside this one). + /// /// internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response) { @@ -1268,9 +1347,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable { // Silent state reset — retail shows no dialog (ACE sends // Pending for a disabled-Olthoi rejection; port as-is). - _revision++; - Publish(RuntimeCharacterCreationDeltaKind.StateChanged); - return; + kind = RuntimeCharacterCreationDeltaKind.StateChanged; } else { diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index eb6a07a1..2c289825 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -334,6 +334,53 @@ public sealed class RuntimeCharacterSelectionState : IDisposable selected); } + /// + /// Campaign CC slice CC3 review-fix round (F2): appends ONE freshly + /// created character without re-deriving every entry's + /// from display + /// order the way does. ActiveIndex is a + /// WIRE CONTRACT — SendDeleteCharacter sends it as the + /// CharacterSet slot and ACE indexes + /// session.Characters[(int)characterSlot] + /// (references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:297) + /// — so round-tripping the post-create roster through + /// 's name-sort would silently retarget every + /// PRE-EXISTING character's delete slot to its alphabetical rank. + /// is the caller-supplied wire slot for the + /// NEW entry only (ACE appends to session.Characters, so the new + /// character's slot equals the wire roster's count BEFORE this create, + /// 0-based — the caller must read that from the cached wire source, not + /// from the sorted display mirror this class exposes). Every existing + /// entry's is + /// copied through untouched; only the array's DISPLAY order (name sort, + /// greyed-to-tail) is recomputed, exactly like 's + /// own sort. Does not touch highlight — callers that want the new entry + /// selected still call afterward. + /// + internal void AppendCreatedCharacter(uint characterId, string name, int wireIndex) + { + ArgumentNullException.ThrowIfNull(name); + lock (_gate) + { + ThrowIfDisposed(); + var appended = new RuntimeCharacterSelectionEntry[_entries.Length + 1]; + Array.Copy(_entries, appended, _entries.Length); + appended[^1] = new RuntimeCharacterSelectionEntry( + wireIndex, + characterId, + name, + SecondsGreyedOut: 0u); + + Array.Sort( + appended, + static (left, right) => + string.CompareOrdinal(left.Name, right.Name)); + _entries = StablePartitionGreyedToTail(appended); + _revision++; + } + Publish(RuntimeCharacterSelectionDeltaKind.RosterChanged, characterId); + } + /// /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName /// (0x004ec120) / RecvNotice_WorldName (0x004ec360) diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs index ae80851f..d3f93985 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -165,6 +165,33 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Equal(new ChargenAttributeValues(10, 10, 10, 10, 10, 10), state.Snapshot.Attributes); } + /// + /// F4 acceptance gate: CharGenState::ConstrainAllByHeritage @ + /// 0x005C65CC clamps a stale template index to 0xffffffff + /// when it no longer fits the newly selected heritage's template list. + /// Without this, a high template index chosen against a + /// many-templates heritage would survive a switch to a heritage with + /// fewer templates and reach the wire via BuildRequestLocked. + /// + [Fact] + public void TrySelectHeritage_TemplateOutOfRangeForNewHeritage_ClearsToUnset() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + // Index 1 — valid for Aluvian's two templates (Custom=0, Preset=1). + Assert.True(state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex)); + Assert.Equal(1u, state.Snapshot.Template); + + // Impoverished has only ONE template (index 0) — index 1 no longer + // fits; gender (Male) stays valid for Impoverished too, so the + // clamp branch (not the "no gender yet" no-op branch) is the one + // under test. + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.ImpoverishedId)); + + Assert.Equal(RuntimeCharacterCreationSnapshot.TemplateUnset, state.Snapshot.Template); + } + // ── Attributes ────────────────────────────────────────────────────── [Fact] @@ -230,6 +257,95 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Equal(16, state.Snapshot.Attributes.Strength); } + /// + /// F7 acceptance gate: CharGenState::BalanceAttributes @ + /// 0x005C3DF0's persistent cursor (ported as the instance field + /// _attributeBalanceCursor) advances past whichever attribute + /// last absorbed an overspend, so a SECOND overspend in a LATER call + /// does not re-drain the SAME donor the first call already emptied. + /// + [Fact] + public void TrySetAttribute_SuccessiveOverspends_AbsorbFromDifferentAttributes() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + // Str=16, everyone else at the 10 floor, fully spent (66/66) — the + // fixture's only above-floor attribute at the start. + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + + // First overspend: raising Endurance by 1 forces a 1-point + // donation. The cursor starts at Strength (the only above-floor + // attribute), so Strength donates. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11)); + Assert.Equal(15, state.Snapshot.Attributes.Strength); + Assert.Equal(11, state.Snapshot.Attributes.Endurance); + + // Second overspend: raising Coordination by 1 forces another + // 1-point donation. If the cursor had reset to Strength, Strength + // (still above floor at 15) would donate again — it doesn't: the + // cursor advanced past Strength after the first call, so THIS + // donation comes from Endurance (the attribute the FIRST call just + // raised) instead. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Coordination, 11)); + Assert.Equal(15, state.Snapshot.Attributes.Strength); // untouched this time + Assert.Equal(10, state.Snapshot.Attributes.Endurance); // donated + Assert.Equal(11, state.Snapshot.Attributes.Coordination); + } + + /// + /// F7 acceptance gate, the wrap case: when the donor found in one pass + /// is the LAST entry in the fixed round-robin order (Self — see + /// BalanceOrder's own doc comment: Strength, Endurance, + /// Coordination, Quickness, Focus, Self), the cursor wraps back to the + /// FIRST entry (Strength) rather than falling off the end. + /// + [Fact] + public void TrySetAttribute_BalanceCursor_WrapsFromSelfBackToStrength() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + // Str=16, everyone else at the 10 floor, fully spent (66/66). + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.PresetTemplateIndex); + + // Lock every attribute except Strength and Self: they stay in the + // budget total but are excluded from donation, isolating the wrap + // behavior to exactly the two attributes under test. + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Endurance, true)); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Coordination, true)); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Quickness, true)); + Assert.True(state.TrySetAttributeLock(ChargenAttributeId.Focus, true)); + + // Move all 6 spare points from Strength to Self — Strength is the + // sole eligible donor (everything else is locked or is the raise + // target), so it donates all 6. The cursor lands just past + // Strength (index 0 → Endurance). + Assert.True(state.TrySetAttribute(ChargenAttributeId.Self, 16)); + Assert.Equal(10, state.Snapshot.Attributes.Strength); + Assert.Equal(16, state.Snapshot.Attributes.Self); + + // Raise Strength by 1: every locked attribute is skipped, so the + // search reaches Self (the only remaining eligible donor). Self is + // the LAST entry in the round-robin order, so this absorption + // wraps the cursor back to Strength (the FIRST entry) afterward. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Strength, 11)); + Assert.Equal(11, state.Snapshot.Attributes.Strength); + Assert.Equal(15, state.Snapshot.Attributes.Self); + + // Raise the (locked) Endurance attribute by 1 — locking only + // excludes an attribute from AUTOMATIC donation, not from being set + // directly. If the cursor wrapped correctly, the donor search + // starts at Strength again and Strength (still above floor at 11) + // donates FIRST — not Self (also still above floor at 15), which is + // what an un-wrapped cursor stuck past Self would have picked + // instead. + Assert.True(state.TrySetAttribute(ChargenAttributeId.Endurance, 11)); + Assert.Equal(10, state.Snapshot.Attributes.Strength); + Assert.Equal(15, state.Snapshot.Attributes.Self); // unchanged — proves the wrap + Assert.Equal(11, state.Snapshot.Attributes.Endurance); + } + // ── Skills ────────────────────────────────────────────────────────── [Fact] @@ -342,6 +458,40 @@ public sealed class RuntimeCharacterCreationStateTests Assert.True(refusal.AttributeCreditsUnspent); } + /// + /// F3 acceptance gate: gmCharGenMainUI::DoFinish(this, arg2) @ + /// 0x004E9170's credit gate is arg2 != 0 && + /// remainingAtrbCredits > 0 — retail does NOT force a full + /// spend. The ordinary click warns and refuses + /// (arg2 = 1 @ 0x004E9579, tested above); the credit-warning + /// dialog's own confirm handler re-invokes DoFinish(this, 0) + /// (@0x004E98BB), which skips the check entirely and sends with the + /// credits still unspent. confirmedUnspentCredits: true is that + /// arg2 == 0 case. + /// + [Fact] + public void TryBeginFinish_UnspentAttributeCreditsConfirmed_IsAccepted() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + state.TrySelectTemplate(RuntimeCharacterCreationStateFixture.CustomTemplateIndex); // 6 unspent + state.TrySetName("Adventurer"); + Assert.Equal(6, state.Snapshot.RemainingAttributeCredits); + + bool accepted = state.TryBeginFinish( + 0, 11, out CharacterCreate.Request request, out _, + out RuntimeCharacterCreationLocalRefusal refusal, + confirmedUnspentCredits: true); + + Assert.True(accepted); + Assert.False(refusal.Any); + Assert.True(state.Snapshot.VerificationPending); + // The wire request carries the credits AS UNSPENT — confirming does + // not force-spend them, it only skips the local refusal. + Assert.Equal(10u, request.Attributes.Strength); + } + [Fact] public void TryBeginFinish_SecondCallWhilePending_IsRefused() { diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs index 6b5eb3d5..d6048d67 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -49,6 +49,13 @@ public sealed class LiveSessionControllerCharacterCreationTests public List Sessions { get; } = []; public int EnterWorldCount { get; private set; } + /// F1/F11: captures every guid-based enter call so the + /// post-create log-straight-in can be asserted against the EXACT + /// identity sent, instead of a bare counter that cannot tell an + /// index-based call from a guid-based one, or a right character + /// from a wrong one. + public List<(uint Guid, string AccountName)> EnterWorldByGuidCalls { get; } = []; + public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); @@ -63,9 +70,19 @@ public sealed class LiveSessionControllerCharacterCreationTests public void StartCharacterSelectionReceive(WorldSession session) { } + /// Two pre-existing characters whose WIRE order (array + /// position — "Zed" slot 0, "Amy" slot 1) deliberately differs from + /// their ALPHABETICAL display order ("Amy" < "Zed") — F2's + /// regression gate. A single-character roster cannot distinguish a + /// correct wire-index-preserving append from a buggy + /// re-sort-and-renumber, because with only one entry the two orders + /// coincide. public CharacterList.Parsed? GetCharacters(WorldSession session) => new( 0u, - [new CharacterList.Character(0x50000001u, "Existing", 0u)], + [ + new CharacterList.Character(0x50000002u, "Zed", 0u), + new CharacterList.Character(0x50000003u, "Amy", 0u), + ], [], SlotCount: 11, AccountName: "testaccount", @@ -75,6 +92,12 @@ public sealed class LiveSessionControllerCharacterCreationTests public void EnterWorld(WorldSession session, int activeCharacterIndex) => EnterWorldCount++; + public void EnterWorldByGuid( + WorldSession session, + uint characterGuid, + string accountName) => + EnterWorldByGuidCalls.Add((characterGuid, accountName)); + public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) { } @@ -195,17 +218,36 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.Equal("NewChar", host.Created[0].Name); Assert.Empty(host.Failed); - // The roster report following the Ok reply has BOTH the pre-existing + // The roster report following the Ok reply has EVERY pre-existing // character and the newly created one. LiveSessionRosterReport lastReport = host.Rosters[^1]; Assert.Contains(lastReport.Entries, e => e.Id == 0x50001234u && e.Name == "NewChar"); - Assert.Contains(lastReport.Entries, e => e.Id == 0x50000001u); + Assert.Contains(lastReport.Entries, e => e.Id == 0x50000002u && e.Name == "Zed"); + Assert.Contains(lastReport.Entries, e => e.Id == 0x50000003u && e.Name == "Amy"); - // gmCharGenMainUI::Update @ 0x004E8460's log-straight-in, reused via - // EnterSelectedCore — the controller is now in-world as the new - // character, no second selection/EnterWorld call needed. + // F2 acceptance gate: the append preserves every PRE-EXISTING + // character's original wire ActiveIndex ("Zed" slot 0, "Amy" slot + // 1 — deliberately NOT alphabetical order) and assigns the NEW + // character the true wire count (2, 0-based, after Zed and Amy). A + // round-trip through ApplyRoster's name-sort-and-renumber would + // have swapped Zed/Amy to 1/0 instead. + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed)); + Assert.Equal(0, zed.ActiveIndex); + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy)); + Assert.Equal(1, amy.ActiveIndex); + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry newChar)); + Assert.Equal(2, newChar.ActiveIndex); + + // F1 acceptance gate: gmCharGenMainUI::Update @ 0x004E8460's + // log-straight-in, reused via EnterCreatedCharacterCore — the + // controller is now in-world as the new character, entered by the + // EXACT guid the Ok reply carried (NOT the roster-index path, which + // the cached wire roster is by-design stale for post-create). Assert.True(controller.IsInWorld); - Assert.Equal(1, operations.EnterWorldCount); + Assert.Equal(0, operations.EnterWorldCount); + Assert.Single(operations.EnterWorldByGuidCalls); + Assert.Equal(0x50001234u, operations.EnterWorldByGuidCalls[0].Guid); + Assert.Equal("testaccount", operations.EnterWorldByGuidCalls[0].AccountName); Assert.Single(host.EnteredWorld); Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); } @@ -230,6 +272,7 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.Empty(host.Created); Assert.False(controller.IsInWorld); Assert.Equal(0, operations.EnterWorldCount); + Assert.Empty(operations.EnterWorldByGuidCalls); Assert.Empty(host.EnteredWorld); // No roster append on a rejection. Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); @@ -254,6 +297,43 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.False(sent); } + /// + /// F3 acceptance gate: retail does NOT force a full attribute spend — + /// gmCharGenMainUI::DoFinish(this, arg2) @ 0x004E9170 only warns + /// (arg2 != 0 && remainingAtrbCredits > 0) and the + /// credit-warning dialog's own confirm handler re-invokes + /// DoFinish(this, 0) (@0x004E98BB), which skips the check + /// entirely and sends. 's + /// confirmUnspentCredits parameter is that arg2 == 0 case. + /// + [Fact] + public void Finish_WithUnspentCreditsAndConfirmed_SendsAnyway() + { + (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); + Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); + Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.CustomTemplateIndex).Accepted); + Assert.True(controller.SetName(generation, "NewChar").Accepted); + WorldSession session = operations.Sessions[0]; + byte[]? captured = null; + session.GameMessageCapture = (body, _) => captured = body; + + // Unconfirmed: refused, matching the sibling test above — the + // warning-dialog gate. + Assert.False(controller.Finish(generation).Accepted); + Assert.Null(captured); + + // Confirmed: sends anyway with the credits still unspent. + RuntimeCommandResult confirmed = controller.Finish(generation, confirmUnspentCredits: true); + + Assert.True(confirmed.Accepted); + Assert.NotNull(captured); + CapturedCreateRequest decoded = DecodeCreateRequest(captured!); + Assert.Equal("NewChar", decoded.Name); + Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged. + } + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) { MethodInfo method = typeof(WorldSession).GetMethod( From 3a6b7e31159b637b6e54bffffeafcf6cc0c62243 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 15:20:03 +0200 Subject: [PATCH 086/138] =?UTF-8?q?fix(runtime)+docs:=20CC3=20re-review=20?= =?UTF-8?q?CLOSED=20=E2=80=94=20R1=20second-create=20wire=20slot,=20risk-8?= =?UTF-8?q?=20measured=20LATENT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CC3 narrow re-review returned CLOSED (both lenses PASS, merge recommended) with residual R1: the post-create wire-slot assignment read the cached wire CharacterList count, which ACE never refreshes after a create — correct for the first create, off by one for a second create in the same session (reachable via create Ok -> server-rejected guid enter -> ReturnToSelection -> create again), the same wire-contract failure class F2 fixed. Root fix now rather than carried: a creates-since-CharacterList counter (the equivalent of retail's own CharacterSet growing via AddIdentity per create), reset on every fresh wire CharacterList apply and at generation reset, applied only to the cached-wire branch since the display-roster fallback already contains prior appends. Regression test drives the full create->Ok->rejected-enter->create-again flow and pins wire slots 0/1/2/3. Docs: CC3 ledger row flipped to REVIEW-CLOSED with real shas (re-review R2); CC7 risk item 8 downgraded to LATENT with measured installed-DAT data (user-prompted): every heritage's single cost override is Arcane Lore at NormalCost=0/PrimaryCost=2 vs global 4/6, so ACE's over-deduction (= NormalCost = 0) cannot fire with end-of-retail data — the earlier "may be rejected" claim was inferred from code without measuring. Runtime 1707/0 Release. Co-Authored-By: Claude Fable 5 --- .../2026-08-15-character-creation-campaign.md | 16 ++++- .../Session/LiveSessionController.cs | 29 +++++++- ...SessionControllerCharacterCreationTests.cs | 67 ++++++++++++++++++- 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index f65b5179..2019c2ff 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -219,7 +219,19 @@ worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7. acdream sent the byte-correct 0xF656 body. If CC7's gate hits this, it is an ACE-side bug reproduced from its own source, NOT an acdream wire or math defect — do not "fix" acdream's cost math to match ACE's - over-deduction. Register: file an AD row if CC7 needs a documented + over-deduction. **MEASURED 2026-08-15 (user-prompted — downgrades this + landmine to LATENT):** dumping the installed EoR DAT shows every one of + the 13 heritages' single override is skill 14 (Arcane Lore) at + NormalCost=0 / PrimaryCost=2, versus global TrainedCost=4 / + SpecializedCost=6. ACE's over-deduction equals NormalCost — which is + ZERO for the only heritage-priced skill — so ACE charges 0+2=2 and + retail's client computes 2: they AGREE, and no character build can + trigger the rejection with end-of-retail data. The formula bug in ACE's + heritage-override branch is real but unfireable here; it only matters + if a custom server ships a DAT whose heritage override has a nonzero + NormalCost. The earlier "may be REJECTED" inference was made from code + without measuring the data — the C4 closeout's observe-don't-infer + lesson, again. Register: file an AD row if CC7 needs a documented workaround (e.g. picking a Specialized skill combination that avoids the heritage-priced skill for the connected gate) rather than silently adjusting acdream's send. @@ -238,7 +250,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 | FIX ROUND COMPLETE 2026-08-15 (Opus dual-lens review found blocking findings F1-F4 on the controller integration, retail fidelity PASS; fix round addressed F1-F16; narrow re-review owed) | `9a84230c` (implementation), this fix round (see git log — sha unknowable pre-commit) | Blocking findings fixed (see fix-round summary below); narrow re-review owed | `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. | +| 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 | — | | | | | CC5 | — | | | | | CC6a | — | | | | diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index c169fce8..4eba5e94 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -433,6 +433,16 @@ public sealed class LiveSessionController private bool _disposed; private ulong _generation; private RuntimeTeardownStage _lastTeardownStages; + + /// + /// CC3 re-review R1: creates accepted since the last wire + /// CharacterList was applied. ACE appends each created character to + /// its own session list but never resends the list, so the cached + /// wire count under-counts by exactly this number — the equivalent + /// of retail's own CharacterSet growing via AddIdentity per create. + /// Guarded by _gate like every sibling field. + /// + private int _createsSinceCharacterList; private LiveSessionCharacterSelection? _activeSelection; private Action? _autoSaveTickHook; private Action? _preLogoffFlushHook; @@ -745,6 +755,7 @@ public sealed class LiveSessionController RuntimeGenerationToken activeGeneration = new(generation); CharacterSelectionState.Reset(activeGeneration); CharacterCreationState.Reset(activeGeneration); + _createsSinceCharacterList = 0; try { DrainRetiredScope(); @@ -818,6 +829,7 @@ public sealed class LiveSessionController CharacterList.Parsed? characters = _operations.GetCharacters(session); if (characters is not null) { + _createsSinceCharacterList = 0; LiveSessionRosterReport roster = BuildRosterReport(characters); CharacterSelectionState.ApplyRoster(roster); host.ReportRoster(roster); @@ -947,6 +959,7 @@ public sealed class LiveSessionController { if (!IsCurrent(scope, generation)) return; + _createsSinceCharacterList = 0; LiveSessionRosterReport report = BuildRosterReport(roster); CharacterSelectionState.ApplyRoster(report); scope.Host.ReportRoster(report); @@ -1337,9 +1350,22 @@ public sealed class LiveSessionController // THIS character by design, but its COUNT is still exactly the // 0-based slot ACE assigned). Falls back to the display roster // count only if the cached wire list is unexpectedly unavailable. + // CC3 re-review R1: the cached count is stale by the number of + // creates since the last CharacterList (ACE never resends one + // post-create), so a SECOND create in the same session must add + // the creates the cache hasn't seen — retail's own CharacterSet + // grows via AddIdentity per create, keeping GetSlot correct the + // same way. The counter applies ONLY to the cached-wire branch: + // the display-roster fallback already contains every prior + // create (AppendCreatedCharacter added them), so adding the + // counter there would double-count. Resets whenever a fresh wire + // CharacterList is applied and at generation reset. int wireIndex = _operations.GetCharacters(scope.Session)?.Characters.Count - ?? before.RosterCount; + is int cachedWireCount + ? cachedWireCount + _createsSinceCharacterList + : before.RosterCount; + _createsSinceCharacterList++; CharacterSelectionState.AppendCreatedCharacter( identity.Guid, identity.Name, @@ -1662,6 +1688,7 @@ public sealed class LiveSessionController _activeSelection = null; CharacterSelectionState.Reset(new RuntimeGenerationToken(_generation)); CharacterCreationState.Reset(new RuntimeGenerationToken(_generation)); + _createsSinceCharacterList = 0; if (_scope is { } scope) { _scope = null; diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs index d6048d67..91ebbdb4 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -92,11 +92,25 @@ public sealed class LiveSessionControllerCharacterCreationTests public void EnterWorld(WorldSession session, int activeCharacterIndex) => EnterWorldCount++; + /// R1: when positive, the next guid-enter throws retail's + /// server-rejection shape (the transport-valid path that returns + /// the controller to selection), decrementing per call — lets a + /// test reach the create-again-after-rejected-enter flow. + public int EnterWorldByGuidRejectionsRemaining { get; set; } + public void EnterWorldByGuid( WorldSession session, uint characterGuid, - string accountName) => + string accountName) + { EnterWorldByGuidCalls.Add((characterGuid, accountName)); + if (EnterWorldByGuidRejectionsRemaining > 0) + { + EnterWorldByGuidRejectionsRemaining--; + throw new CharacterSelectionRejectedException( + new CharacterError.Parsed(0x0000000Bu)); + } + } public void Tick(WorldSession session) { } @@ -252,6 +266,57 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.Equal(0x50001234u, host.EnteredWorld[0].CharacterId); } + /// + /// CC3 re-review R1: a SECOND create in the same session must get wire + /// slot N+1, not N. ACE never resends CharacterList post-create, so the + /// cached wire count alone under-counts by the creates it hasn't seen; + /// the controller's creates-since-list counter (reset on every fresh + /// wire CharacterList) supplies the difference — the equivalent of + /// retail's own CharacterSet growing via AddIdentity per create. The + /// create-again path is reached exactly as the re-review described: + /// first create Ok, guid-enter rejected by the server + /// (CharacterSelectionRejectedException → ReturnToSelection), then a + /// second create. + /// + [Fact] + public void SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot() + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + operations.EnterWorldByGuidRejectionsRemaining = 1; + + Assert.True(controller.Finish(generation).Accepted); + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); + + // The rejected enter left us back at selection with the first + // created character appended at the true wire slot 2. + Assert.False(controller.IsInWorld); + Assert.Single(operations.EnterWorldByGuidCalls); + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50001234u, out RuntimeCharacterSelectionEntry firstCreated)); + Assert.Equal(2, firstCreated.ActiveIndex); + + // Second create in the same session: ACE's own list now holds + // Zed(0), Amy(1), NewChar(2) — the cached wire list still only + // holds Zed and Amy. The second character's slot must be 3. + Assert.True(controller.SetName(generation, "SecondChar").Accepted); + Assert.True(controller.Finish(generation).Accepted); + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.Ok, 0x50005678u, "SecondChar")); + + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50005678u, out RuntimeCharacterSelectionEntry secondCreated)); + Assert.Equal(3, secondCreated.ActiveIndex); + // Pre-existing wire indices still intact after both appends. + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000002u, out RuntimeCharacterSelectionEntry zed)); + Assert.Equal(0, zed.ActiveIndex); + Assert.True(controller.CharacterSelectionState.View.TryGet(0x50000003u, out RuntimeCharacterSelectionEntry amy)); + Assert.Equal(1, amy.ActiveIndex); + Assert.Equal(2, host.Created.Count); + } + [Fact] public void Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection() { From 55bfd9ca820fdf4695e4fca8b2547fad8f48ca6f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 17:21:34 +0200 Subject: [PATCH 087/138] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice?= =?UTF-8?q?=20CC6a=20=E2=80=94=20index=E2=86=92ObjDesc=20factory=20+=20pre?= =?UTF-8?q?view=20renderer=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers the CC6a foundation half of the chargen 3D preview: the missing index->ObjDesc appearance factory the campaign plan's acdream-seams section named, plus a static-pose offscreen renderer following PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture. Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges). Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces): ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's ObjDesc rebuild in its exact decompiled order - base body, hair style, clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the UI tab order or the wire's field order, both of which differ), eyes (bald-aware), nose, mouth, then the unconditional skin subpalette, hair color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's shade-to-index formula, cross-checked three ways (decomp control flow, ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's identical slider math). ChargenPalSet/ChargenClothingTable are pure projections behind IChargenPalSetSource/IChargenClothingTableSource so the factory itself never touches a dat. Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the cached dat-backed implementation of those two source interfaces, mirroring ChargenTableReader's no-leak discipline. App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and CreatureAppraisalViewportRenderer - no existing rendering file touched. ChargenPreviewCamera carries the four retail-verbatim per-heritage eye profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked against ZoomIn/ZoomOut's identical literals) plus the recovered rotation (3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the decompiler's garbled float literals - the plan's own "measure if it matters" note is resolved, not garbled beyond recovery). Rotation applies to the character model, not the camera, per gmCGAppearancePage::DoRotation. ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself (there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's surface-override algorithm and RetailPaperdollPoseApplicator's held-pose technique, generalized to chargen's per-heritage rest-pose DID. Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail- idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the un-ported clothing Setup-substitution fallback chain costs nothing for the 9 standard heritages with clothing UI, but Undead's default gear choices genuinely lack ClothingBaseEffects coverage for Undead's own body Setup). Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests (installed-DAT-gated, proves a real 34-part Aluvian mesh resolves). Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all pre-existing skips, zero failures, full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 4 +- .../2026-08-15-character-creation-campaign.md | 2 +- .../Rendering/ChargenPreviewCamera.cs | 176 +++++++ .../Rendering/ChargenPreviewEntityBuilder.cs | 258 +++++++++++ .../Rendering/ChargenPreviewRenderer.cs | 80 ++++ .../CharGen/ChargenAppearanceCatalog.cs | 105 +++++ .../CharGen/ChargenAppearanceFactory.cs | 350 ++++++++++++++ .../CharGen/ChargenAppearanceSelection.cs | 52 +++ .../CharGen/ChargenClothingTable.cs | 143 ++++++ src/AcDream.Core/CharGen/ChargenPalSet.cs | 23 + src/AcDream.Core/CharGen/ChargenPalSetMath.cs | 49 ++ .../Rendering/ChargenPreviewCameraTests.cs | 110 +++++ .../ChargenPreviewEntityBuilderTests.cs | 127 +++++ ...argenAppearanceCatalogInstalledDatTests.cs | 155 +++++++ .../CharGen/ChargenAppearanceFactoryTests.cs | 436 ++++++++++++++++++ .../CharGen/ChargenPalSetMathTests.cs | 63 +++ 16 files changed, 2131 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.App/Rendering/ChargenPreviewCamera.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewRenderer.cs create mode 100644 src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs create mode 100644 src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs create mode 100644 src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs create mode 100644 src/AcDream.Core/CharGen/ChargenClothingTable.cs create mode 100644 src/AcDream.Core/CharGen/ChargenPalSet.cs create mode 100644 src/AcDream.Core/CharGen/ChargenPalSetMath.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs create mode 100644 tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 913e52fc..1d9d05d4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -389,10 +389,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-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) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 50 active rows (TS-83 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview holds a static rest-pose final frame instead of retail's live 30fps idle loop, explicitly staged for CC6b to retire; TS-82 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) to leave Undead's default headgear/trousers/footwear preview unclothed; TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| TS-83 | Chargen 3D preview (Campaign CC slice CC6a foundation): the preview holds a STATIC final-frame rest pose (`ChargenPreviewEntityBuilder.ApplyHeldPose`, retail's `m_didAnimationRest` DID resolution) instead of retail's live 30fps idle loop (`gmCG3DView`'s `m_didAnimation`/`m_didAnimArray` family, driven via `set_sequence_animation`). Deliberately staged, not discovered late: the campaign plan's own CC6 slice row names this exact split ("CC6a static-pose preview... register row for the missing idle loop, CC6b idle animation... retire the row"). | `src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs` (`ApplyHeldPose`); `src/AcDream.App/Rendering/ChargenPreviewRenderer.cs` | Explicitly staged per `docs/plans/2026-08-15-character-creation-campaign.md`'s CC6 slice split; the identical held-pose technique is the paperdoll's own PERMANENT (not staged) design (`RetailPaperdollPoseApplicator.Apply`), so the mechanism itself is proven, only the "hold forever vs. play then hold" choice is temporary here. | The chargen preview shows a motionless character instead of retail's idle sway/breathing loop — cosmetic only; does not affect the composed appearance data (setup id, palette, part/texture overrides) CC6b's page will bind to. | `gmCG3DView` ctor + `::Update @ 0x004EE9D0` (`m_didAnimation`/`m_didAnimArray`/`m_didAnimationRest` DID assignments, pseudo-C ~0x004EE7C6-0x004EE995); `CreatureMode::set_sequence_animation` (idle-loop playback entry point, not yet located precisely — CC6b to find) | +| TS-82 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`: the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default headgear/trousers/footwear choices (both genders) have NO base-effect entry for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage) rather than pervasive. | Undead's default headgear/trousers/footwear preview renders the bare body mesh for those three slots (no clothing part/texture override applied, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | | TS-76 | Five Character-tab rows have no acdream consumer at all (research doc §4.2's own "state-only, no consumer" list, narrowed to the ids NOT already closed by Campaign OP's Group-C re-points): "Display 3D Tooltips" (`ShowTooltips`), "Side By Side Vitals" (`SideBySideVitals`), "Display Spell Durations" (`SpellDuration`), "Advanced Combat Interface" (`AdvancedCombatUI`), "Stay in Chat Mode After Sending a Message" (`StayInChatMode`) — retail renders 3D item tooltips, an alternate side-by-side vitals layout, remaining-duration overlays on enchantment icons, an expanded combat panel, and a chat-input-stays-open behavior respectively; acdream has none of the four rendering surfaces and no chat-input-close-on-send behavior to gate in the first place. | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (the rows wire+store only) | Each needs a real UI/behavior feature built before the option means anything — inventing a stand-in now would be exactly the workaround CLAUDE.md forbids. | Toggling any of the five writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes. | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; `EffectInfoRegion::Update @0x004f1c00`; `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `ChatInterface::HandleEnterKey @0x004f52d0`; `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 2019c2ff..fd27cd37 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -253,6 +253,6 @@ the user gate. | 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 | — | | | | | CC5 | — | | | | -| CC6a | — | | | | +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | single commit, HEAD of `campaign-cc6a` | PENDING (Opus dual-lens not yet run this session) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — three-way agreement, no guessing:** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c"), ACViewer's identical `ClothingTableList.xaml.cs:97` slider math, AND the decomp's own control-flow shape. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default headgear/trousers/footwear choices genuinely miss `ClothingBaseEffects` coverage for Undead's own live body Setup on both genders — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests:** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (19 hand-built-fixture cases covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity). Final counts this session: Core.Tests 4767/1 skip, Content.Tests 146/0 skips, App.Tests 5121/6 skips — all pre-existing skips, zero failures, full solution Release build green. | | CC6b | — | | | | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs new file mode 100644 index 00000000..eeb907ad --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -0,0 +1,176 @@ +using System; +using System.Numerics; +using AcDream.Core.CharGen; + +namespace AcDream.App.Rendering; + +/// +/// Heritage-parameterized camera for the chargen 3D preview +/// (gmCG3DView, Appearance page viewport 0x100003bb / Summary +/// 0x10000406). Retail-exact eye positions, ported from +/// gmCGAppearancePage::Update @ 0x0047E8F0 (pseudo-C ~139037-139114, +/// which sets m_vectTargPosition/m_vectCurPosition per +/// heritage and snaps them together with no tween — CC6a's static preview +/// renders that snapped default, the "zoomed-in" framing) and cross-checked +/// against the IDENTICAL literals in gmCGAppearancePage::ZoomIn @ +/// 0x0047CF00 (pseudo-C ~137618-137638). Direction is always +/// (0,0,0)CreatureMode::SetCameraDirection resets the view +/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention +/// already established for the paperdoll (look +/// straight down +Y, +Z up); every camera position below is used AS the +/// world-space eye directly, matching that camera's approach. +/// +/// +/// Rotation is NOT a camera property. Retail's continuous-rotation +/// button (gmCGAppearancePage::DoRotation @ 0x0047CA80) advances a +/// HEADING applied to the preview CHARACTER (CPhysicsObj::set_heading +/// inside gmCG3DView::Update, pseudo-C ~242088) — the camera's own +/// position/direction never change during a rotation. CC6b's heading +/// parameter therefore belongs on the entity builder +/// (), not here; this class stays a +/// fixed-per-heritage eye, exactly like retail's own camera. +/// +/// +public sealed class ChargenPreviewCamera : ICamera +{ + private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as DollCamera/ChaseCamera. + + private Vector3 _eye; + + public ChargenPreviewCamera(uint heritageId = 0u) + { + _eye = ResolveDefaultEye(heritageId); + } + + /// + /// The camera's current world-space eye. Settable so CC6b can react to a + /// heritage change without reconstructing the camera. + /// + public Vector3 Eye + { + get => _eye; + set => _eye = value; + } + + /// Re-derives for the given heritage id (retail's mHeritageGroup). + public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId); + + /// + /// Retail default (zoomed-in) camera eye per heritage. All four profiles + /// share X=0; only (Y, Z) — the AC world-space forward + /// offset and height — vary. FOUR distinct profiles across the 13 + /// heritages, not five: standard heritages (Aluvian, Gharu'ndim, Sho, + /// Viamontian, Shadowbound, Gearknight, Lugian, Empyrean, Penumbraen, + /// Undead — everything except Tumerok/Olthoi/OlthoiAcid) share the SAME + /// numeric offset as Gearknight's own dedicated branch in the decomp. + /// + public static Vector3 ResolveDefaultEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -1.85000002f, 1.85000002f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -3.04999995f, 2.75f), + (uint)ChargenHeritageGroup.Tumerok => new Vector3(0f, -0.850000024f, 1.64999998f), + _ => new Vector3(0f, -0.550000012f, 1.64999998f), + }; + + /// + /// Retail zoomed-OUT camera eye per heritage + /// (gmCGAppearancePage::ZoomOut @ 0x0047D050, pseudo-C + /// ~137671-137687). CC6a does not implement the zoom button (CC6b) — + /// recorded here as the verified target CC6b's tween will animate + /// toward. Olthoi/OlthoiAcid each keep their own dedicated profile; + /// every other heritage — INCLUDING Tumerok, whose zoomed-IN profile is + /// special-cased but whose zoomed-OUT is not — shares one value. + /// + public static Vector3 ResolveZoomedOutEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -3.79999995f, 1.14999998f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -5.69999981f, 1.64999998f), + _ => new Vector3(0f, -2.5f, 0.95f), + }; + + /// + /// Seconds per 360° revolution for the continuous-rotation button + /// (gmCGAppearancePage::m_dRotationPerSec, ctor pseudo-C + /// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000, + /// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly, + /// no reconstruction needed). Consumed by CC6b's rotation controller as + /// 360f / RotationDegreesPerSecond — NOT applied here; see this + /// class's own doc comment on why rotation is not a camera concern. + /// + public const float RotationSecondsPerRevolution = 3.0f; + + /// + /// Zoom tween duration in seconds + /// (gmCGAppearancePage::DoZoomAnimation @ 0x0047C960's + /// reset-if-invalid default, cross-confirmed by ZoomIn/ZoomOut's + /// own -0.1 sentinel write, which deliberately invalidates + /// m_dAnimDuration so the very next DoZoomAnimation tick + /// resets it to this same value). The campaign plan flagged this + /// constant as decompiler-garbled (both sites split the raw double + /// across two 32-bit stores, and the decompiler mis-renders the LOW + /// dword's store as a bogus float literal instead of raw bits) — it is + /// NOT unrecoverable: reinterpreting each garbled float literal as its + /// own raw 32-bit pattern and pairing it with the store's (clean) high + /// dword reconstructs an exact IEEE-754 double both times. + /// DoZoomAnimation's own reset path: low32 from + /// 4.17232506e-08f reinterpreted = 0x33333333, high32 = + /// 0x3fe33333 (clean) → exactly 0.6. Cross-check via + /// ZoomIn/ZoomOut's sentinel: low32 from + /// -1.58818684e-23f reinterpreted = 0x9999999A, high32 = + /// 0xbfb99999 (clean) → exactly -0.1, the well-known + /// IEEE-754 bit pattern for -0.1 (0xBFB999999999999A) — confirming + /// the reconstruction technique itself, not just this one value. + /// + public const float ZoomTweenDurationSeconds = 0.6f; + + public float FovRadians { get; set; } = MathF.PI / 4f; // retail CreatureMode default, same as DollCamera. + public float Near { get; set; } = 0.1f; + public float Far { get; set; } = 50f; + public float Aspect { get; set; } = 1f; + + public Matrix4x4 View => + Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Up); + + public Matrix4x4 Projection => + Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far); +} + +/// +/// Internal private-viewport adapter, mirroring DollViewportCamera's +/// role for . +/// +internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera +{ + private readonly ChargenPreviewCamera _camera; + + public ChargenPreviewViewportCamera(uint heritageId = 0u) + { + _camera = new ChargenPreviewCamera(heritageId); + } + + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public Vector3 Eye => _camera.Eye; + public float FovRadians + { + get => _camera.FovRadians; + set => _camera.FovRadians = value; + } + public float Near + { + get => _camera.Near; + set => _camera.Near = value; + } + public float Far + { + get => _camera.Far; + set => _camera.Far = value; + } + public float Aspect + { + get => _camera.Aspect; + set => _camera.Aspect = value; + } + public Matrix4x4 View => _camera.View; + public Matrix4x4 Projection => _camera.Projection; +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs new file mode 100644 index 00000000..3b79de88 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -0,0 +1,258 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Meshing; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Builds the static-pose chargen preview from a +/// — the App-layer counterpart to +/// , except this one resolves its OWN +/// MeshRefs from a Setup + the composed ObjDesc rather than receiving +/// already-resolved refs from a live entity (there is no live entity yet; +/// character creation hasn't happened). DAT-touching, unlike +/// 's pure index-agnostic builder — the +/// closest existing precedent for the actual mesh-flatten/apply-changes/ +/// resolve-surface-overrides steps is +/// DatLiveEntityProjectionMaterializer.TryMaterialize, trimmed to +/// what a private, non-animated, non-collision preview scene needs. +/// +internal static class ChargenPreviewEntityBuilder +{ + /// Reserved synthetic guid for the chargen preview clone — + /// same reserved family as + /// (0xDA11D0xx) and CreatureAppraisalEntityBuilder (0xDA11D02x). + public const uint PreviewServerGuid = 0xDA11_D031u; + + /// Reserved render-local entity id — passed in + /// animatedEntityIds by the renderer so a re-dress (a new + /// selection) bypasses WbDrawDispatcher's Tier-1 classification + /// cache, mirroring 's own + /// doc comment. + public const uint PreviewRenderId = 0xDA11_D032u; + + /// + /// Retail's held-pose animation DID enum key, resolved through master + /// map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid + /// — 0x10000005 for every standard heritage (the SAME enum id the + /// paperdoll's own held pose reads), matching + /// gmCG3DView's ctor / ::Update per-heritage + /// m_didAnimationRest assignment (pseudo-C ~0x004EE948, + /// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest + /// DID — the one divergence from the paperdoll, which never needs an + /// Olthoi branch because a live player can't be one. + /// + private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => 0x10000011u, + (uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u, + _ => 0x10000005u, + }; + + /// + /// Builds the preview entity, or null when the resolved body Setup + /// isn't in the dat source (a corrupted/incomplete install — the same + /// failure shape treats + /// as "drop this spawn"). + /// + public static WorldEntity? TryBuild( + IDatReaderWriter dats, + IAnimationLoader animations, + ChargenAppearanceResult appearance, + uint heritageId, + Quaternion heading) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(animations); + ArgumentNullException.ThrowIfNull(appearance); + + Setup? setup = dats.Get(appearance.SetupId); + if (setup is null) + return null; + + var flattened = new List(SetupMesh.Flatten(setup)); + + foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges) + { + if (change.PartIndex < flattened.Count) + flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); + } + + ApplyHeldPose(dats, animations, setup, heritageId, flattened); + + Dictionary>? surfaceOverrides = + ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges); + + var meshRefs = new List(flattened.Count); + for (int partIndex = 0; partIndex < flattened.Count; partIndex++) + { + MeshRef part = flattened[partIndex]; + if (dats.Get(part.GfxObjId) is null) + continue; // matches DatLiveEntityProjectionMaterializer's drawable filter. + + IReadOnlyDictionary? overrides = null; + if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) + overrides = perPart; + + meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + } + if (meshRefs.Count == 0) + return null; + + PaletteOverride? paletteOverride = null; + if (appearance.ObjDesc.SubPalettes.Count > 0) + { + var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count]; + for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++) + { + ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i]; + ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors); + } + paletteOverride = new PaletteOverride(appearance.BasePaletteId, ranges); + } + + var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count]; + for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++) + { + ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i]; + partOverrides[i] = new PartOverride(change.PartIndex, change.PartId); + } + + return new WorldEntity + { + Id = PreviewRenderId, + ServerGuid = PreviewServerGuid, + SourceGfxObjOrSetupId = appearance.SetupId, + Position = Vector3.Zero, + Rotation = heading, + MeshRefs = meshRefs, + PaletteOverride = paletteOverride, + PartOverrides = partOverrides, + ParentCellId = null, + }; + } + + /// + /// Overwrites every part's transform from the resolved rest pose's + /// FINAL frame — same "hold the settled last frame at zero frame rate" + /// approach as RetailPaperdollPoseApplicator.Apply + /// (RedressCreature @ 0x004A3C22), applied to the FULL + /// setup-part-indexed array (before drawable filtering) so the index + /// alignment holds even if a later part turns out to have a missing + /// GfxObj. No-ops (keeps the default placement frame) when the pose + /// DID or its animation can't be resolved. + /// + private static void ApplyHeldPose( + IDatReaderWriter dats, + IAnimationLoader animations, + Setup setup, + uint heritageId, + List flattened) + { + uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId)); + if ((poseDid >> 24) != 0x03u) + return; + + Animation? animation = animations.LoadAnimation(poseDid); + if (animation is null || animation.PartFrames.Count == 0) + return; + + var frame = animation.PartFrames[^1]; + for (int index = 0; index < flattened.Count; index++) + { + Vector3 scale = index < setup.DefaultScale.Count ? setup.DefaultScale[index] : Vector3.One; + Vector3 origin = Vector3.Zero; + Quaternion orientation = Quaternion.Identity; + if (index < frame.Frames.Count) + { + origin = frame.Frames[index].Origin; + orientation = frame.Frames[index].Orientation; + } + + Matrix4x4 transform = Matrix4x4.CreateScale(scale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + flattened[index] = new MeshRef(flattened[index].GfxObjId, transform); + } + } + + /// + /// DBCache::GetDIDFromEnumStatic(poseEnum, 7) equivalent — verbatim + /// port of RetailPaperdollPoseApplicator.ResolvePoseDid, + /// parameterized by the target enum key. + /// + private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum) + { + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; + if (masterDid == 0 + || !dats.Portal.TryGet(masterDid, out var master) + || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) + || !dats.Portal.TryGet(subDid, out var sub)) + { + return 0u; + } + + return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u; + } + + /// + /// Part-index → (old texture id → new texture id) resolution, verbatim + /// port of DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides's + /// algorithm against instead of the + /// wire's CreateObject.TextureChange. + /// + private static Dictionary>? ResolveSurfaceOverrides( + IDatReaderWriter dats, + IReadOnlyList parts, + IReadOnlyList textureChanges) + { + if (textureChanges.Count == 0) + return null; + + var oldToNewByPart = new Dictionary>(); + foreach (ChargenTextureChange change in textureChanges) + { + if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew)) + { + oldToNew = []; + oldToNewByPart.Add(change.PartIndex, oldToNew); + } + oldToNew[change.OldTextureId] = change.NewTextureId; + } + + var result = new Dictionary>(); + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew)) + continue; + + GfxObj? gfx = dats.Get(parts[partIndex].GfxObjId); + if (gfx is null) + continue; + + Dictionary? resolved = null; + foreach (var surfaceQid in gfx.Surfaces) + { + uint surfaceId = (uint)surfaceQid; + Surface? surface = dats.Get(surfaceId); + if (surface is null) + continue; + uint originalTexture = (uint)surface.OrigTextureId; + if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture)) + continue; + + (resolved ??= [])[surfaceId] = newTexture; + } + + if (resolved is not null) + result[partIndex] = resolved; + } + + return result.Count == 0 ? null : result; + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs new file mode 100644 index 00000000..ec6c92c8 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -0,0 +1,80 @@ +using AcDream.App.Rendering.Wb; +using AcDream.App.UI; +using AcDream.Core.Lighting; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Chargen-specific facade over the shared private creature viewport +/// () — CC6a's foundation half of +/// the campaign plan's "chargen preview renderer" deliverable. Mirrors +/// 's shape exactly, with a +/// heading-capable in place of the +/// paperdoll's fixed one. +/// +/// +/// NOT wired here (CC6b, after CC4 merges per the campaign's parallelism +/// contract): mounting into the authored Appearance/Summary viewport ids +/// (0x100003bb / 0x10000406), spin/color-wheel controls, and +/// the rotate/zoom buttons. This class is a standalone, composition-root- +/// agnostic renderer — nothing in AcDream.App/UI/Layout/ or +/// RetailUiRuntime.cs references it yet. +/// +/// +/// +/// Register row (staged deviation, retired by CC6b): retail plays a +/// live 30fps idle loop in the preview +/// (gmCG3DView's m_didAnimation/m_didAnimArray, +/// set_sequence_animation, distinct from the STATIC +/// m_didAnimationRest this class's entity builder uses). CC6a holds +/// the static rest-pose final frame only — see +/// docs/architecture/retail-divergence-register.md. +/// +/// +internal sealed class ChargenPreviewRenderer : + IUiViewportRenderer, + IDisposable +{ + private readonly PrivateEntityViewportRenderer _renderer; + private readonly ChargenPreviewViewportCamera _camera; + + internal ChargenPreviewRenderer( + IWorldPassScope scope, + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frames, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IEntityTextureLifetime textureLifetime, + IWbMeshAdapter meshAdapter, + uint heritageId = 0u) + { + _camera = new ChargenPreviewViewportCamera(heritageId); + _renderer = new PrivateEntityViewportRenderer( + scope, + device, + frames, + dispatcher, + lightUbo, + textureLifetime, + meshAdapter, + ChargenPreviewEntityBuilder.PreviewRenderId, + _camera, + "chargen preview"); + } + + public bool TextureIsBottomUp => _renderer.TextureIsBottomUp; + + /// + /// Re-derives the fixed per-heritage camera eye + /// () — call whenever + /// the selected heritage changes, BEFORE the next . + /// + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity); + + public uint Render(int width, int height) => _renderer.Render(width, height); + + public void Dispose() => _renderer.Dispose(); +} diff --git a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs new file mode 100644 index 00000000..fe1c7579 --- /dev/null +++ b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs @@ -0,0 +1,105 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; +using AcDream.Core.CharGen; +using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable; +using DatPalSet = DatReaderWriter.DBObjs.PalSet; +using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect; +using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette; + +namespace AcDream.Content.CharGen; + +/// +/// DAT-backed / +/// implementation: reads PalSet (0x0F......) and ClothingTable (0x19......) +/// dat objects on demand and projects them into 's +/// pure Core types, matching ChargenTableReader's "no Chorizite leak" +/// discipline for everything it returns. Both lookups cache by dat id — a +/// live preview re-composes on every appearance change, and the same +/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and +/// re-selections within one session. +/// +public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource +{ + private readonly IDatReaderWriter _dats; + private readonly ConcurrentDictionary _palSets = new(); + private readonly ConcurrentDictionary _clothingTables = new(); + + public ChargenAppearanceCatalog(IDatReaderWriter dats) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + } + + public ChargenPalSet? TryGetPalSet(uint palSetId) => + _palSets.GetOrAdd(palSetId, LoadPalSet); + + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _clothingTables.GetOrAdd(clothingTableId, LoadClothingTable); + + private ChargenPalSet? LoadPalSet(uint id) + { + DatPalSet? palSet = _dats.Get(id); + if (palSet is null) + return null; + + var ids = new uint[palSet.Palettes.Count]; + for (int i = 0; i < palSet.Palettes.Count; i++) + ids[i] = palSet.Palettes[i].DataId; + return new ChargenPalSet(Array.AsReadOnly(ids)); + } + + private ChargenClothingTable? LoadClothingTable(uint id) + { + DatClothingTable? table = _dats.Get(id); + if (table is null) + return null; + + var baseEffects = new Dictionary( + table.ClothingBaseEffects.Count); + foreach (var pair in table.ClothingBaseEffects) + baseEffects[pair.Key.DataId] = ProjectBaseEffect(pair.Value.CloObjectEffects); + + var templates = new Dictionary( + table.ClothingSubPalEffects.Count); + foreach (var pair in table.ClothingSubPalEffects) + templates[pair.Key] = ProjectPaletteTemplate(pair.Value.CloSubPalettes); + + return new ChargenClothingTable( + baseEffects.ToFrozenDictionary(), + templates.ToFrozenDictionary()); + } + + private static ChargenClothingBaseEffect ProjectBaseEffect( + IReadOnlyList objectEffects) + { + var partChanges = new List(objectEffects.Count); + var textureChanges = new List(); + foreach (DatCloObjectEffect effect in objectEffects) + { + var partIndex = (byte)effect.Index; + partChanges.Add(new ChargenAnimPartChange(partIndex, effect.ModelId.DataId)); + foreach (var tex in effect.CloTextureEffects) + { + textureChanges.Add(new ChargenTextureChange( + partIndex, tex.OldTexture.DataId, tex.NewTexture.DataId)); + } + } + return new ChargenClothingBaseEffect( + Array.AsReadOnly(partChanges.ToArray()), + Array.AsReadOnly(textureChanges.ToArray())); + } + + private static ChargenClothingPaletteTemplate ProjectPaletteTemplate( + IReadOnlyList subPalettes) + { + var choices = new ChargenClothingSubPaletteChoice[subPalettes.Count]; + for (int i = 0; i < subPalettes.Count; i++) + { + DatCloSubPalette sub = subPalettes[i]; + var ranges = new ChargenClothingSubPaletteRange[sub.Ranges.Count]; + for (int j = 0; j < sub.Ranges.Count; j++) + ranges[j] = new ChargenClothingSubPaletteRange(sub.Ranges[j].Offset, sub.Ranges[j].NumColors); + choices[i] = new ChargenClothingSubPaletteChoice(sub.PaletteSet.DataId, Array.AsReadOnly(ranges)); + } + return new ChargenClothingPaletteTemplate(Array.AsReadOnly(choices)); + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs new file mode 100644 index 00000000..c091471a --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs @@ -0,0 +1,350 @@ +namespace AcDream.Core.CharGen; + +/// +/// The resolved render description +/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies +/// to it (CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0 is +/// retail's equivalent apply step). The three diagnostic lists let callers +/// (and CC6a's installed-DAT test) verify a selection resolved with no +/// missing dat data without needing to re-walk the composition themselves. +/// +/// +/// The body Setup dat id (0x02......) to build the preview mesh from — +/// gender.SetupId, overridden by the selected hair style's +/// AlternateSetup when nonzero (Gear Knight / Undead / Tumerok body +/// variants), falling back to +/// when both are zero (retail: CPhysicsObj::makeObject(setupId)'s own +/// HUMAN_SETUP_ID fallback, gmCG3DView ctor pseudo-C ~0x004EE79D and +/// gmCG3DView::Update ~0x004EEA61). +/// +/// +/// gender.BasePaletteId (retail Sex_CG.BasePalette) — the +/// palette a mesh builder should pass as the entity's base, NOT +/// ObjDesc.PaletteId (retail's own on-disk BaseObjDesc.PaletteId +/// field is unused for this purpose; cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:58, +/// which sets PropertyDataId.PaletteBase from sex.BasePalette +/// directly). +/// +/// +/// The composed subpalette/texture/part-swap deltas, in retail's exact +/// application order (see ). +/// +public sealed record ChargenAppearanceResult( + uint SetupId, + uint BasePaletteId, + ChargenObjDesc ObjDesc, + IReadOnlyList MissingPalSetIds, + IReadOnlyList MissingClothingTableIds, + IReadOnlyList ClothingTablesMissingBaseEffectForSetup); + +/// +/// Index→ObjDesc appearance factory: the missing piece the campaign plan's +/// "acdream seams" section names (Appearance building: DollEntityBuilder.Build +/// is index-agnostic but reads a LIVE entity; chargen needs a new index→dat +/// →ObjDesc factory). Pure — no Chorizite types on this type's public +/// surface, matching CC1's ChargenOptions family; PalSet/ClothingTable +/// dat reads are pushed behind / +/// , whose production implementation +/// (AcDream.Content.CharGen.ChargenAppearanceCatalog) does the actual +/// dat work. +/// +/// +/// Ports gmCG3DView::Update @ 0x004EE9D0's ObjDesc rebuild verbatim, +/// in its EXACT append order (verified against the decompiled control flow, +/// not inferred from the UI's tab order or the wire's field order, both of +/// which differ — see the per-slot XML doc below): +/// +/// +/// Base body (Sex_CG.BaseObjDesc). +/// Hair style overlay (HairStyle_CG.ObjDesc), if selected. +/// Clothing, in retail's own order — Headgear, Trousers, Shirt, +/// Footwear (NOT the UI tab order 5/6/7/8 = headgear/shirt/trousers/ +/// footwear, and NOT the wire field order from CC2's 0xF656 builder, +/// which is also headgear/shirt/trousers/footwear). Each slot applies +/// its ClothingBase part/texture overrides unconditionally, then +/// — only when a color is also selected — its dye subpalette via +/// ClothingTable::BuildObjDesc @ 0x005A7900. +/// Eyes strip overlay (bald variant when the selected hair style's +/// Bald flag is set), if selected. +/// Nose strip overlay, if selected. +/// Mouth strip overlay, if selected. +/// Skin subpalette — UNCONDITIONAL, no "if selected" guard in +/// retail (the decompiled block runs every time, unlike every style/ +/// color slot above and below it, which all gate on retail's +/// 0xFFFFFFFF sentinel). +/// Hair color subpalette, if selected. +/// Eye color subpalette, if selected. +/// +/// +public static class ChargenAppearanceFactory +{ + /// + /// Retail's HUMAN_SETUP_ID fallback (ACViewer.Entity.Enum.SetupConst.HumanMale + /// = 0x02000001; the same constant gmCG3DView's ctor and + /// ::Update fall back to when no valid body Setup is resolvable). + /// + public const uint HumanSetupId = 0x02000001u; + + /// + /// Skin subpalette overlay range, retail's hard-coded literal at + /// gmCG3DView::Update ~0x004EF066-0x004EF07E: real byte offset 0, + /// real color count 192 (0xC0), packed to 's + /// *8 on-disk units as (0, 24). + /// + private const byte SkinRangeOffset = 0; + private const byte SkinRangeNumColors = 24; // 192 / 8 + + /// + /// Hair color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF0FA-0x004EF116: real offset 192 (0xC0), real count 64 (0x40), + /// packed to (24, 8). + /// + private const byte HairRangeOffset = 24; // 192 / 8 + private const byte HairRangeNumColors = 8; // 64 / 8 + + /// + /// Eye color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF15A-0x004EF16E: real offset 256 (0x100), real count 64 + /// (0x40), packed to (32, 8). + /// + private const byte EyeRangeOffset = 32; // 256 / 8 + private const byte EyeRangeNumColors = 8; // 64 / 8 + + /// + /// Composes a preview appearance description for one heritage/gender + + /// selection, or returns false when the heritage/gender itself doesn't + /// resolve (mirrors the Try* convention + /// already uses). Never throws on missing PalSet/ClothingTable data — + /// a miss is recorded in the result's diagnostic lists and that single + /// contribution is skipped, matching retail's own "hash miss → no-op, + /// caller never checks BuildObjDesc's return value" behavior. + /// + public static bool TryCompose( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection, + IChargenPalSetSource palSets, + IChargenClothingTableSource clothingTables, + out ChargenAppearanceResult result) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(palSets); + ArgumentNullException.ThrowIfNull(clothingTables); + + result = default!; + if (!options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + || !heritage.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender)) + { + return false; + } + + var missingPalSets = new List(); + var missingClothingTables = new List(); + var absentBaseEffects = new List(); + + // ── 1. body Setup id ──────────────────────────────────────────── + uint setupId = gender.SetupId; + ChargenHairStyle? hairStyle = null; + if (selection.HairStyle != ChargenAppearanceSelection.Unset + && selection.HairStyle < (uint)gender.HairStyles.Count) + { + hairStyle = gender.HairStyles[(int)selection.HairStyle]; + if (hairStyle.AlternateSetup != 0) + setupId = hairStyle.AlternateSetup; + } + if (setupId == 0) + setupId = HumanSetupId; + + // ── 2. ObjDesc accumulation, retail's exact append order ─────── + var subPalettes = new List(); + var textureChanges = new List(); + var animPartChanges = new List(); + + Append(gender.BaseObjDesc, subPalettes, textureChanges, animPartChanges); + if (hairStyle is not null) + Append(hairStyle.ObjDesc, subPalettes, textureChanges, animPartChanges); + + ComposeClothingSlot( + gender.Headgears, selection.HeadgearStyle, + gender.ClothingColors, selection.HeadgearColor, selection.HeadgearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Pants, selection.TrousersStyle, + gender.ClothingColors, selection.TrousersColor, selection.TrousersShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Shirts, selection.ShirtStyle, + gender.ClothingColors, selection.ShirtColor, selection.ShirtShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Footwear, selection.FootwearStyle, + gender.ClothingColors, selection.FootwearColor, selection.FootwearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + + if (selection.EyesStrip != ChargenAppearanceSelection.Unset + && selection.EyesStrip < (uint)gender.EyeStrips.Count) + { + ChargenEyeStrip strip = gender.EyeStrips[(int)selection.EyesStrip]; + bool bald = hairStyle?.Bald == true; + Append(bald ? strip.BaldObjDesc : strip.ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.NoseStrip != ChargenAppearanceSelection.Unset + && selection.NoseStrip < (uint)gender.NoseStrips.Count) + { + Append(gender.NoseStrips[(int)selection.NoseStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.MouthStrip != ChargenAppearanceSelection.Unset + && selection.MouthStrip < (uint)gender.MouthStrips.Count) + { + Append(gender.MouthStrips[(int)selection.MouthStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + + // ── Skin subpalette: UNCONDITIONAL (no selection gate in retail) ─ + ChargenPalSet? skinPalSet = palSets.TryGetPalSet(gender.SkinPalSetId); + if (skinPalSet is null) + { + missingPalSets.Add(gender.SkinPalSetId); + } + else + { + int skinIndex = ChargenPalSetMath.GetPaletteIndex(skinPalSet.PaletteIds.Count, selection.SkinShade); + if (skinIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + skinPalSet.PaletteIds[skinIndex], SkinRangeOffset, SkinRangeNumColors)); + } + } + + if (selection.HairColor != ChargenAppearanceSelection.Unset + && selection.HairColor < (uint)gender.HairColors.Count) + { + uint hairPalSetId = gender.HairColors[(int)selection.HairColor]; + ChargenPalSet? hairPalSet = palSets.TryGetPalSet(hairPalSetId); + if (hairPalSet is null) + { + missingPalSets.Add(hairPalSetId); + } + else + { + int hairIndex = ChargenPalSetMath.GetPaletteIndex(hairPalSet.PaletteIds.Count, selection.HairShade); + if (hairIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + hairPalSet.PaletteIds[hairIndex], HairRangeOffset, HairRangeNumColors)); + } + } + } + + if (selection.EyeColor != ChargenAppearanceSelection.Unset + && selection.EyeColor < (uint)gender.EyeColors.Count) + { + // Direct Palette id — no PalSet/shade indirection (see ChargenPalSet's doc). + uint eyePaletteId = gender.EyeColors[(int)selection.EyeColor]; + subPalettes.Add(new ChargenSubPalette(eyePaletteId, EyeRangeOffset, EyeRangeNumColors)); + } + + var objDesc = new ChargenObjDesc( + gender.BasePaletteId, + subPalettes.AsReadOnly(), + textureChanges.AsReadOnly(), + animPartChanges.AsReadOnly()); + + result = new ChargenAppearanceResult( + setupId, + gender.BasePaletteId, + objDesc, + missingPalSets.AsReadOnly(), + missingClothingTables.AsReadOnly(), + absentBaseEffects.AsReadOnly()); + return true; + } + + private static void Append( + ChargenObjDesc source, + List subPalettes, + List textureChanges, + List animPartChanges) + { + subPalettes.AddRange(source.SubPalettes); + textureChanges.AddRange(source.TextureChanges); + animPartChanges.AddRange(source.AnimPartChanges); + } + + private static void ComposeClothingSlot( + IReadOnlyList gearOptions, + uint styleIndex, + IReadOnlyList clothingColors, + uint colorIndex, + double shade, + uint bodySetupId, + IChargenClothingTableSource clothingTables, + IChargenPalSetSource palSets, + List subPalettes, + List textureChanges, + List animPartChanges, + List missingClothingTables, + List missingPalSets, + List absentBaseEffects) + { + if (styleIndex == ChargenAppearanceSelection.Unset || styleIndex >= (uint)gearOptions.Count) + return; + + ChargenGearOption gear = gearOptions[(int)styleIndex]; + ChargenClothingTable? table = clothingTables.TryGetClothingTable(gear.ClothingTableId); + if (table is null) + { + missingClothingTables.Add(gear.ClothingTableId); + return; + } + + if (table.BaseEffectsBySetupId.TryGetValue(bodySetupId, out ChargenClothingBaseEffect? baseEffect)) + { + animPartChanges.AddRange(baseEffect.PartChanges); + textureChanges.AddRange(baseEffect.TextureChanges); + } + else + { + absentBaseEffects.Add(gear.ClothingTableId); + } + + if (colorIndex == ChargenAppearanceSelection.Unset || colorIndex >= (uint)clothingColors.Count) + return; + + uint paletteTemplateId = clothingColors[(int)colorIndex]; + if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template)) + return; // retail: hash miss on the palette-template lookup is a silent no-op. + + foreach (ChargenClothingSubPaletteChoice choice in template.Choices) + { + ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId); + if (palSet is null) + { + missingPalSets.Add(choice.PalSetId); + continue; + } + + int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade); + if (index < 0) + continue; + + uint paletteId = palSet.PaletteIds[index]; + foreach (ChargenClothingSubPaletteRange range in choice.Ranges) + { + subPalettes.Add(new ChargenSubPalette( + paletteId, + (byte)(range.Offset / 8), + (byte)(range.NumColors / 8))); + } + } + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs new file mode 100644 index 00000000..efe2d421 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs @@ -0,0 +1,52 @@ +namespace AcDream.Core.CharGen; + +/// +/// The fourteen style/color indices plus the six f64 shades +/// needs to build a preview +/// description — field-for-field the same shape as CC3's +/// AcDream.Runtime.Session.RuntimeCharacterCreationAppearance (and, +/// through it, CharacterCreate.Appearance's wire fields), kept as a +/// SEPARATE type here rather than referenced directly because +/// AcDream.Runtime depends on AcDream.Core and not the other +/// way around. CC6b's job is the trivial field-by-field copy from the +/// Runtime owner's snapshot into this type. / +/// mirror retail's own sentinels exactly (same +/// citations CC3 already recorded): 0xFFFFFFFF for "nothing selected" +/// and the IEEE-754 -1.0 construction-time shade default +/// (CharGenState::Reset @ 0x005C68A0). +/// +public readonly record struct ChargenAppearanceSelection( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairStyle, + uint HairColor, + uint EyeColor, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade) +{ + public const uint Unset = 0xFFFFFFFFu; + public const double UnsetShade = -1.0; + + public static ChargenAppearanceSelection Default { get; } = new( + Unset, Unset, Unset, + Unset, Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + UnsetShade, UnsetShade, UnsetShade, + UnsetShade, UnsetShade, UnsetShade); +} diff --git a/src/AcDream.Core/CharGen/ChargenClothingTable.cs b/src/AcDream.Core/CharGen/ChargenClothingTable.cs new file mode 100644 index 00000000..f46227f9 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenClothingTable.cs @@ -0,0 +1,143 @@ +using System.Collections.Frozen; + +namespace AcDream.Core.CharGen; + +/// +/// One un-resolved dye-shade choice inside a clothing "palette template" +/// (retail's inner CloSubpalEffect array entry, one per +/// ClothingTable::BuildObjDesc @ 0x005A7900 loop iteration; Chorizite +/// projects the identical shape as DatReaderWriter.Types.CloSubPalette +/// — a PaletteSet id plus a list of overlay ranges). Offsets/counts +/// here are the REAL (unpacked) color units read straight off the dat +/// (installed-DAT probe: Aluvian male "Cloth Cap" headgear reads +/// off=2000,n=48 for every one of its 28 palette-template entries) — the +/// *8-packed byte convention only applies to the OUTPUT +/// , converted once at composition time +/// (). +/// +public readonly record struct ChargenClothingSubPaletteRange(uint Offset, uint NumColors); + +/// +/// One resolvable-by-shade colour choice for a clothing palette template: +/// the PalSet id (0x0F......) to resolve via +/// , plus every overlay range +/// to apply once resolved. +/// +public readonly record struct ChargenClothingSubPaletteChoice( + uint PalSetId, + IReadOnlyList Ranges); + +/// +/// One clothing-table "palette template" (retail's CloPaletteTemplate, +/// looked up in ClothingTable::_paletteTemplatesHash by the id +/// CharGenState::GetHeadgearPaletteTemplateID (and its Shirt/Trousers/ +/// Footwear siblings, all at 0x005C38F0-0x005C3980) return — which is itself +/// just a bounds-checked passthrough of Sex_CG.ClothingColors[index]: +/// every one of the four per-slot template-id arrays +/// (headgearPaletteTemplateIDs/shirtPaletteTemplateIDs/ +/// trousersPaletteTemplateIDs/footwearPaletteTemplateIDs) is +/// populated from the SAME single Sex_CG::ClothingColors dat field — +/// there is no per-clothing-slot color list in the dat schema at all. This +/// CONFIRMS (does not merely approximate) register row AP-208's shared-list +/// design in RuntimeCharacterCreationAppearance/ +/// ChargenAppearanceSlot — installed-DAT probe: Aluvian male's +/// ClothingColors = {9,6,4,8,7,5,2,3,13}, and the "Cloth Cap" +/// headgear's ClothingSubPalEffects keys include 2,3,4,5,6,7,8,9,13 — +/// the shared list's raw values ARE the template-id keys, verified live. +/// +public sealed record ChargenClothingPaletteTemplate( + IReadOnlyList Choices) +{ + public static ChargenClothingPaletteTemplate Empty { get; } = + new(Array.Empty()); +} + +/// +/// One body-Setup-specific part/texture override set (retail's +/// ClothingBaseEffect, applied by +/// ClothingBase::ApplyPartAndTextureChanges @ 0x005A8EB0): for each +/// CloObjectEffect, an unconditional +/// (part index → replacement GfxObj) plus every +/// the SAME object effect carries for +/// that part. +/// +public sealed record ChargenClothingBaseEffect( + IReadOnlyList PartChanges, + IReadOnlyList TextureChanges) +{ + public static ChargenClothingBaseEffect Empty { get; } = new( + Array.Empty(), + Array.Empty()); +} + +/// +/// Pure projection of one ClothingTable dat object (0x19......, retail +/// ClothingTable::Unpack / Chorizite +/// DatReaderWriter.DBObjs.ClothingTable). One instance is referenced +/// per — a single garment +/// CHOICE (e.g. "Cloth Cowl") carries its own table covering every body +/// Setup it can be worn on plus every dye choice offered for it. +/// +/// +/// Deliberate scope cut (CC6a) — MEASURED, not just asserted: retail's +/// ClothingTable::BuildObjDesc falls back through a chain of ~8 +/// hard-coded Setup-id substitutions (Umbraen crown/no-crown/void, +/// Penumbraen, Undead skeleton/zombie, Anakshay) when +/// has no direct entry for the requested +/// body Setup. CC6a's composer looks up +/// directly and skips a slot's part/texture contribution on a miss +/// (matching retail's own "hash miss → BuildObjDesc returns failure, caller +/// does not check it, ObjDesc keeps whatever it already had" behavior) +/// rather than porting the substitution chain. The installed-DAT catalog +/// test (ChargenAppearanceCatalogInstalledDatTests) MEASURED this +/// directly across all 26 heritage/gender combinations rather than assuming +/// it: for the 9 standard heritages where retail's own UI actually shows +/// clothing controls (everything except Gear Knight and the two Olthoi +/// variants, which retail hides the clothes button for entirely — +/// gmCGAppearancePage::Update @ 0x0047E8F0's +/// m_pClothesButton->SetVisible(0) branches for +/// mHeritageGroup == 6 and == 0xc || == 0xd), the default +/// gear choices resolve against their own body Setup with ZERO missing +/// coverage. Undead IS a real gap — retail DOES show clothing +/// controls for Undead, but its default headgear/trousers/footwear choices +/// have no entry for either gender's +/// live Setup id (measured: 4 of 4 non-shirt slots miss, on both genders), +/// because Undead's live body Setup IS one of the skeleton/zombie variants +/// the un-ported substitution chain exists to redirect. A live preview for +/// Undead will therefore render its default headgear/trousers/footwear +/// choice with NO part/texture override applied (the underlying body shows +/// through unclothed for those slots) until the substitution chain — or an +/// equivalent per-heritage default-clothing-setup mapping — lands. Filed as +/// a known CC6a limitation for CC6b/a follow-up rather than silently +/// "confirmed unreachable." +/// +/// +public sealed record ChargenClothingTable( + IReadOnlyDictionary BaseEffectsBySetupId, + IReadOnlyDictionary PaletteTemplatesById) +{ + public static ChargenClothingTable Empty { get; } = new( + FrozenDictionary.Empty, + FrozenDictionary.Empty); +} + +/// +/// Resolves a PalSet dat id (0x0F......) to its pure projection. The +/// production implementation (AcDream.Content.CharGen.ChargenAppearanceCatalog) +/// reads and caches the real dat object; this interface keeps +/// free of any Chorizite dependency +/// (unit tests supply a hand-built fake). +/// +public interface IChargenPalSetSource +{ + ChargenPalSet? TryGetPalSet(uint palSetId); +} + +/// +/// Resolves a ClothingTable dat id (0x19......) to its pure projection. +/// Same production/test split as . +/// +public interface IChargenClothingTableSource +{ + ChargenClothingTable? TryGetClothingTable(uint clothingTableId); +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSet.cs b/src/AcDream.Core/CharGen/ChargenPalSet.cs new file mode 100644 index 00000000..e822d92c --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSet.cs @@ -0,0 +1,23 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure projection of a PalSet dat object (0x0F......, retail +/// PalSet::Unpack / Chorizite DatReaderWriter.DBObjs.PalSet): +/// the ordered list of Palette dat ids (0x04......) a shade fraction picks +/// from via . Every appearance +/// color slot that resolves "by shade" — skin (ChargenGenderOptions.SkinPalSetId), +/// hair (ChargenGenderOptions.HairColors[i]), and every clothing +/// dye choice (ChargenClothingSubPaletteChoice.PalSetId) — reads one +/// of these. Eye color is the one exception: retail uses the raw entry +/// from ChargenGenderOptions.EyeColors directly as a Palette id, no +/// PalSet/shade indirection (gmCG3DView::Update pseudo-C ~0x004EF12F; +/// cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:100, +/// which sets EyesPalette straight from sex.EyeColorList[eyeColor] +/// with no GetPaletteID call, unlike the Skin/Hair lines immediately +/// above it). +/// +public sealed record ChargenPalSet(IReadOnlyList PaletteIds) +{ + public static ChargenPalSet Empty { get; } = new(Array.Empty()); +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSetMath.cs b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs new file mode 100644 index 00000000..68cdb042 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs @@ -0,0 +1,49 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's shade→palette-index resolution +/// (PalSet::GetPaletteID @ 0x005AC570, invoked from +/// gmCG3DView::Update @ 0x004EE9D0 for the skin/hair subpalette +/// build and from ClothingTable::BuildObjDesc @ 0x005A7900 for every +/// clothing-slot dye choice). The decompiled body is FPU-elided (the x87 +/// bounds-compare against 0.0/1.0 and the truncating _ftol2() cast +/// lose their operands to the decompiler), but ACE's +/// ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID carries the +/// explicit comment "Taken from acclient.c (PalSet::GetPaletteID)" with the +/// exact formula below — corroborated by the decomp's own control-flow +/// shape (a two-sided FPU compare consistent with a [0,1] bounds +/// check, then one truncating cast) and independently by ACViewer's +/// ClothingTableList.xaml.cs:97 UI slider, which reimplements the +/// identical (count - 0.000001) * shade expression for its own shade +/// preview. Three independent sources agree. +/// +public static class ChargenPalSetMath +{ + /// + /// Resolves a shade fraction to an index into a palette-id list of the + /// given . Returns -1 (retail's + /// INVALID_DID outcome) when is + /// non-positive or falls outside + /// [0.0, 1.0] — including retail's own -1.0 "unset" + /// sentinel (CharGenState::Reset @ 0x005C68A0), which is + /// deliberately out of range so an untouched shade resolves to + /// "nothing," matching retail. Callers should treat -1 as "skip this + /// subpalette contribution" rather than emit a placeholder id. + /// + public static int GetPaletteIndex(int count, double shade) + { + if (count <= 0 || shade < 0.0 || shade > 1.0) + return -1; + + // Truncating cast, exactly as ACE's cited port and the decomp's + // _ftol2() (which truncates toward zero on x86, matching a plain + // C-style (int) cast here since count > 0 and 0 <= shade <= 1 keep + // the product non-negative). + int index = (int)((count - 0.000001) * shade); + if (index < 0) + index = 0; + if (index > count - 1) + index = count - 1; + return index; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs new file mode 100644 index 00000000..1b0280f3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Pins 's retail-verbatim per-heritage +/// eye positions (gmCGAppearancePage::Update @ 0x0047E8F0, +/// cross-checked against the identical literals in ZoomIn/ZoomOut +/// @ 0x0047CF00/0x0047D050) and the zero-yaw/zero-pitch look +/// convention DollCameraTests already established for the shared private +/// viewport. +/// +public class ChargenPreviewCameraTests +{ + [Theory] + [InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Gharundim, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Gearknight, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Undead, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -0.850000024f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -1.85000002f, 1.85000002f)] + [InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -3.04999995f, 2.75f)] + public void ResolveDefaultEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z) + { + Vector3 eye = ChargenPreviewCamera.ResolveDefaultEye(heritageId); + Assert.Equal(x, eye.X, 4); + Assert.Equal(y, eye.Y, 4); + Assert.Equal(z, eye.Z, 4); + } + + [Theory] + [InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -2.5f, 0.95f)] + [InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -2.5f, 0.95f)] // ZoomOut has NO Tumerok special case, unlike the zoomed-in default. + [InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -3.79999995f, 1.14999998f)] + [InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -5.69999981f, 1.64999998f)] + public void ResolveZoomedOutEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z) + { + Vector3 eye = ChargenPreviewCamera.ResolveZoomedOutEye(heritageId); + Assert.Equal(x, eye.X, 4); + Assert.Equal(y, eye.Y, 4); + Assert.Equal(z, eye.Z, 4); + } + + [Fact] + public void Constructor_DefaultsToStandardHeritageEye_ForUnknownHeritageId() + { + var cam = new ChargenPreviewCamera(heritageId: 0u); + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(0u), cam.Eye); + } + + [Fact] + public void SetHeritage_UpdatesEyeToTheNewHeritagesProfile() + { + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + cam.SetHeritage((uint)ChargenHeritageGroup.Olthoi); + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye((uint)ChargenHeritageGroup.Olthoi), cam.Eye); + } + + [Fact] + public void View_LooksStraightDownPlusY_ZeroYawZeroPitch() + { + // Same identity-direction convention DollCameraTests pins for the paperdoll: + // retail SetCameraDirection(0,0,0) resets the view frame to IDENTITY. + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian) { Aspect = 1f }; + var forward = -new Vector3(cam.View.M13, cam.View.M23, cam.View.M33); + Assert.Equal(0f, forward.X, 4); + Assert.Equal(1f, forward.Y, 4); + Assert.Equal(0f, forward.Z, 4); + } + + [Fact] + public void Eye_RoundTripsThroughViewMatrixInversion() + { + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Olthoi) { Aspect = 1f }; + Assert.True(Matrix4x4.Invert(cam.View, out var inv)); + Vector3 eye = inv.Translation; + Assert.Equal(cam.Eye.X, eye.X, 3); + Assert.Equal(cam.Eye.Y, eye.Y, 3); + Assert.Equal(cam.Eye.Z, eye.Z, 3); + } + + [Fact] + public void Projection_IsFiniteAndUsesAspect() + { + var cam = new ChargenPreviewCamera { Aspect = 1.5f }; + Assert.True(float.IsFinite(cam.Projection.M11)); + Assert.NotEqual(0f, cam.Projection.M34); + } + + [Fact] + public void RotationSecondsPerRevolution_IsExactlyThreeSeconds() + { + // Raw double bits low32=0x00000000, high32=0x40080000 — no + // reconstruction needed, the decompiler shows this one cleanly. + Assert.Equal(3.0f, ChargenPreviewCamera.RotationSecondsPerRevolution); + } + + [Fact] + public void ZoomTweenDurationSeconds_IsExactlyZeroPointSix() + { + // Recovered by reinterpreting the decompiler's garbled float literal + // as the raw low-32-bit store and pairing it with the (clean) high + // dword; cross-confirmed via the -0.1 sentinel in ZoomIn/ZoomOut + // reconstructing to the well-known IEEE-754 bit pattern for -0.1. + Assert.Equal(0.6f, ChargenPreviewCamera.ZoomTweenDurationSeconds); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs new file mode 100644 index 00000000..8a3f45d3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Content; +using AcDream.Content.CharGen; +using AcDream.Content.Vfx; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Installed-DAT gate for — +/// mirrors 's env-gated skip pattern +/// (no unit-testable pure surface exists here the way +/// has one, because THIS builder's whole job +/// is resolving Setup/GfxObj/Surface/Animation dat data that +/// receives pre-resolved). +/// +public sealed class ChargenPreviewEntityBuilderTests +{ + private readonly ITestOutputHelper _out; + public ChargenPreviewEntityBuilderTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian. + Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)); + + var catalog = new ChargenAppearanceCatalog(adapter); + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + Assert.Empty(appearance.MissingPalSetIds); + Assert.Empty(appearance.MissingClothingTableIds); + + var animations = new RetailAnimationLoader(adapter); + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, appearance, heritageId: 1u, Quaternion.Identity); + + Assert.NotNull(entity); + Assert.NotEmpty(entity!.MeshRefs); + Assert.Equal(appearance.SetupId, entity.SourceGfxObjOrSetupId); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewServerGuid, entity.ServerGuid); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewRenderId, entity.Id); + Assert.NotNull(entity.PaletteOverride); + Assert.Equal(appearance.BasePaletteId, entity.PaletteOverride!.BasePaletteId); + + _out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}"); + } + + [Fact] + public void TryBuild_UnknownSetupId_ReturnsNull() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var animations = new RetailAnimationLoader(adapter); + + var bogusAppearance = new ChargenAppearanceResult( + SetupId: 0x0200_FFFFu, // Not a real installed Setup id. + BasePaletteId: 0u, + ObjDesc: ChargenObjDesc.Empty, + MissingPalSetIds: [], + MissingClothingTableIds: [], + ClothingTablesMissingBaseEffectForSetup: []); + + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity); + + Assert.Null(entity); + } + + [Fact] + public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi)); // Olthoi. + Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male) + || olthoi.GendersByKey.TryGetValue(2, out male)); + Assert.NotNull(male); + int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key; + + var catalog = new ChargenAppearanceCatalog(adapter); + var animations = new RetailAnimationLoader(adapter); + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 }, + catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, appearance, heritageId: 12u, Quaternion.Identity); + + // Just proves the Olthoi branch doesn't throw / silently fall through to + // "no mesh" — the exact pose DID differs internally (0x10000011 vs + // 0x10000005) but both should still resolve a drawable mesh from Olthoi's + // own Setup. + Assert.NotNull(entity); + Assert.NotEmpty(entity!.MeshRefs); + } +} diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs new file mode 100644 index 00000000..b4d08248 --- /dev/null +++ b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs @@ -0,0 +1,155 @@ +using AcDream.Content.CharGen; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit.Abstractions; + +namespace AcDream.Content.Tests.CharGen; + +/// +/// Installed-DAT gate for + +/// together: for every one of the 13 +/// installed heritages' genders, composes a "pick the first offered option +/// everywhere, mid shade" selection and asserts it resolves with no missing +/// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance +/// bar ("every heritage/gender's default selection resolves to a complete +/// description with no missing dat ids"). Also records (without asserting +/// zero — see the class doc on 's +/// deliberate scope cut) how many clothing slots have no +/// ClothingBaseEffects entry for their own gender's body Setup, so a +/// future session can see at a glance whether CC6a's decision to skip +/// retail's Setup-substitution fallback chain ever actually costs +/// coverage on the real dat. +/// +public sealed class ChargenAppearanceCatalogInstalledDatTests +{ + private readonly ITestOutputHelper _out; + public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output; + + private static string? ResolveDatDir() + { + string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + string def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + [Fact] + public void EveryHeritageGendersDefaultSelection_ResolvesWithNoMissingDatIds() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.NotEmpty(options.HeritagesById); + var catalog = new ChargenAppearanceCatalog(adapter); + + int composed = 0; + int absentBaseEffectTotal = 0; + var missingSummaries = new List(); + + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey) + { + ChargenAppearanceSelection selection = MakeDefaultSelection(gender); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritage.HeritageId, genderKey, selection, + catalog, catalog, out ChargenAppearanceResult result); + + Assert.True(ok, $"heritage=0x{heritage.HeritageId:X} gender={genderKey} failed to resolve heritage/gender"); + composed++; + + if (result.MissingPalSetIds.Count > 0 || result.MissingClothingTableIds.Count > 0) + { + missingSummaries.Add( + $"heritage={heritage.Name} gender={genderKey}: " + + $"missingPalSets=[{string.Join(",", result.MissingPalSetIds.Select(id => $"0x{id:X8}"))}] " + + $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]"); + } + + absentBaseEffectTotal += result.ClothingTablesMissingBaseEffectForSetup.Count; + if (result.ClothingTablesMissingBaseEffectForSetup.Count > 0) + { + _out.WriteLine( + $"heritage={heritage.Name} gender={genderKey} setup=0x{result.SetupId:X8}: " + + $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no " + + "ClothingBaseEffects entry for this body setup " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + } + } + } + + _out.WriteLine($"composed {composed} heritage/gender selections; {absentBaseEffectTotal} absent-base-effect slots total."); + Assert.True( + missingSummaries.Count == 0, + "Missing dat ids found:\n" + string.Join('\n', missingSummaries)); + Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}."); + } + + /// + /// "Pick the first offered option everywhere, mid shade" — CC6a's own + /// default policy for exercising the factory end-to-end, NOT a claim + /// about retail's own CharGenState default selection (that policy is + /// CC3/CC6b's concern). Every index/shade starts at + /// / + /// and is only set when the gender's own list actually offers an + /// option, so a heritage with e.g. no headgear choices exercises the + /// factory's "slot not selected" path rather than an out-of-range index. + /// + private static ChargenAppearanceSelection MakeDefaultSelection(ChargenGenderOptions gender) + { + const double midShade = 0.5; + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default; + + if (gender.HairStyles.Count > 0) + selection = selection with { HairStyle = 0u }; + if (gender.EyeStrips.Count > 0) + selection = selection with { EyesStrip = 0u }; + if (gender.NoseStrips.Count > 0) + selection = selection with { NoseStrip = 0u }; + if (gender.MouthStrips.Count > 0) + selection = selection with { MouthStrip = 0u }; + if (gender.HairColors.Count > 0) + selection = selection with { HairColor = 0u, HairShade = midShade }; + if (gender.EyeColors.Count > 0) + selection = selection with { EyeColor = 0u }; + + if (gender.Headgears.Count > 0) + selection = selection with { HeadgearStyle = 0u }; + if (gender.Shirts.Count > 0) + selection = selection with { ShirtStyle = 0u }; + if (gender.Pants.Count > 0) + selection = selection with { TrousersStyle = 0u }; + if (gender.Footwear.Count > 0) + selection = selection with { FootwearStyle = 0u }; + + if (gender.ClothingColors.Count > 0) + { + selection = selection with + { + HeadgearColor = 0u, + HeadgearShade = midShade, + ShirtColor = 0u, + ShirtShade = midShade, + TrousersColor = 0u, + TrousersShade = midShade, + FootwearColor = 0u, + FootwearShade = midShade, + }; + } + + return selection with { SkinShade = midShade }; + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs new file mode 100644 index 00000000..b405a70f --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs @@ -0,0 +1,436 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Hand-built-fixture tests for . +/// Real installed-DAT coverage (every heritage/gender's default selection, +/// verifying no missing PalSet/ClothingTable ids) lives in +/// AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogInstalledDatTests. +/// +public sealed class ChargenAppearanceFactoryTests +{ + private const uint HeritageId = 1u; + private const int GenderKey = 1; + private const uint BodySetupId = 0x0200_0001u; + private const uint AlternateBodySetupId = 0x0200_00FFu; + + private const uint BasePaletteId = 0x0400_0001u; + private const uint SkinPalSetId = 0x0F00_0001u; + private const uint HairColorPalSetId = 0x0F00_0002u; + private const uint EyeColorPaletteId = 0x0400_0099u; // direct palette id, no PalSet indirection. + + private const uint HeadgearClothingTableId = 0x1900_0001u; + private const uint TrousersClothingTableId = 0x1900_0002u; + private const uint ShirtClothingTableId = 0x1900_0003u; + private const uint FootwearClothingTableId = 0x1900_0004u; + + private static ChargenObjDesc MakeObjDesc(uint tag) => new( + 0u, + [], + [new ChargenTextureChange((byte)tag, 0x0500_0000u + tag, 0x0500_1000u + tag)], + [new ChargenAnimPartChange((byte)tag, 0x0100_0000u + tag)]); + + private static ChargenGenderOptions MakeGender(uint alternateHairSetup = 0u, bool baldHairStyle = false) => new( + GenderKey: GenderKey, + Name: "Male", + Scale: 100u, + SetupId: BodySetupId, + SoundTableId: 0x0900_0001u, + IconId: 0x0600_0001u, + BasePaletteId: BasePaletteId, + SkinPalSetId: SkinPalSetId, + PhysicsTableId: 0x0D00_0001u, + MotionTableId: 0x0900_0002u, + CombatTableId: 0x0000_0001u, + BaseObjDesc: MakeObjDesc(0), + HairColors: [HairColorPalSetId], + HairStyles: + [ + new ChargenHairStyle(0x0600_0002u, baldHairStyle, alternateHairSetup, MakeObjDesc(1)), + ], + EyeColors: [EyeColorPaletteId], + EyeStrips: + [ + new ChargenEyeStrip(0x0600_0003u, 0x0600_0004u, MakeObjDesc(2), MakeObjDesc(20)), + ], + NoseStrips: [new ChargenFaceStrip(0x0600_0005u, MakeObjDesc(3))], + MouthStrips: [new ChargenFaceStrip(0x0600_0006u, MakeObjDesc(4))], + Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0x3000_0001u)], + Shirts: [new ChargenGearOption("Shirt", ShirtClothingTableId, 0x3000_0002u)], + Pants: [new ChargenGearOption("Pants", TrousersClothingTableId, 0x3000_0003u)], + Footwear: [new ChargenGearOption("Boots", FootwearClothingTableId, 0x3000_0004u)], + ClothingColors: [7u]); + + private static ChargenOptions MakeOptions(ChargenGenderOptions gender) + { + var heritage = new ChargenHeritageOptions( + HeritageId, "Test", 0x0600_0001u, BodySetupId, BodySetupId, + 180u, 100u, [0], [], + new Dictionary(), [], + new Dictionary { [GenderKey] = gender }); + return new ChargenOptions( + [], + new Dictionary { [HeritageId] = heritage }, + new Dictionary()); + } + + /// One dye choice per clothing table: palette-template id 7, + /// one PalSet, one range (real units 80/16 → packed (10,2)). + private static ChargenClothingTable MakeClothingTable(uint clothingTableId, uint palSetId, uint bodySetupId) + { + var partChanges = new[] { new ChargenAnimPartChange(5, 0x0100_5000u + clothingTableId) }; + var textureChanges = new[] { new ChargenTextureChange(5, 0x0500_5000u, 0x0500_6000u) }; + var baseEffects = new Dictionary + { + [bodySetupId] = new ChargenClothingBaseEffect(partChanges, textureChanges), + }; + var choice = new ChargenClothingSubPaletteChoice( + palSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]); + var templates = new Dictionary + { + [7u] = new ChargenClothingPaletteTemplate([choice]), + }; + return new ChargenClothingTable(baseEffects, templates); + } + + private sealed class FakePalSetSource : IChargenPalSetSource + { + private readonly Dictionary _sets = new(); + public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds); + public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null; + } + + private sealed class FakeClothingTableSource : IChargenClothingTableSource + { + private readonly Dictionary _tables = new(); + public void Add(uint id, ChargenClothingTable table) => _tables[id] = table; + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _tables.TryGetValue(clothingTableId, out var t) ? t : null; + } + + private static (FakePalSetSource pal, FakeClothingTableSource clothing) MakeSources(uint bodySetupId = BodySetupId) + { + var pal = new FakePalSetSource(); + pal.Add(SkinPalSetId, 0x0400_0010u, 0x0400_0011u, 0x0400_0012u); + pal.Add(HairColorPalSetId, 0x0400_0020u, 0x0400_0021u); + var clothingDyePalSetId = 0x0F00_0003u; + pal.Add(clothingDyePalSetId, 0x0400_0030u, 0x0400_0031u); + + var clothing = new FakeClothingTableSource(); + clothing.Add(HeadgearClothingTableId, MakeClothingTable(HeadgearClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(TrousersClothingTableId, MakeClothingTable(TrousersClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(ShirtClothingTableId, MakeClothingTable(ShirtClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(FootwearClothingTableId, MakeClothingTable(FootwearClothingTableId, clothingDyePalSetId, bodySetupId)); + return (pal, clothing); + } + + [Fact] + public void TryCompose_ReturnsFalse_WhenHeritageIsUnknown() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritageId: 999u, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out _); + + Assert.False(ok); + } + + [Fact] + public void TryCompose_ReturnsFalse_WhenGenderIsUnknown() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, genderKey: 999, ChargenAppearanceSelection.Default, + pal, clothing, out _); + + Assert.False(ok); + } + + [Fact] + public void TryCompose_DefaultSelection_ResolvesBodySetupAndUnconditionalSkinSubpalette() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Equal(BodySetupId, result.SetupId); + Assert.Equal(BasePaletteId, result.BasePaletteId); + Assert.Empty(result.MissingPalSetIds); + Assert.Empty(result.MissingClothingTableIds); + + // UnsetShade (-1.0) is out of [0,1], so GetPaletteIndex returns -1 and + // the skin block is skipped for THIS test's default selection — the + // "unconditional" behavior is that the block always RUNS (always + // attempts the PalSet lookup), not that it always emits an entry. + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 0); + // Base body's own ObjDesc still lands (tag 0's texture/anim change). + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 0); + } + + [Fact] + public void TryCompose_SkinShadeSelected_EmitsSkinSubpaletteAtPackedOffsetZeroCountTwentyFour() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { SkinShade = 0.5 }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette skin = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 24); + Assert.Equal(0x0400_0011u, skin.SubPaletteId); // index 1 of 3 at shade 0.5. + } + + [Fact] + public void TryCompose_HairColorSelected_EmitsHairSubpaletteAtPackedOffsetTwentyFourCountEight() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 1.0 }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette hair = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 24 && sp.NumColors == 8); + Assert.Equal(0x0400_0021u, hair.SubPaletteId); // last of the two at shade 1.0. + } + + [Fact] + public void TryCompose_EyeColorSelected_UsesRawPaletteIdDirectlyNoShadeIndirection() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { EyeColor = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette eye = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 32 && sp.NumColors == 8); + Assert.Equal(EyeColorPaletteId, eye.SubPaletteId); + } + + [Fact] + public void TryCompose_HairStyleSelected_AppendsHairObjDescAfterBase() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(0u, (uint)result.ObjDesc.AnimPartChanges[0].PartIndex); // base first. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1); // hair style second. + } + + [Fact] + public void TryCompose_HairStyleWithAlternateSetup_OverridesBodySetupId() + { + ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: AlternateBodySetupId)); + var (pal, clothing) = MakeSources(bodySetupId: AlternateBodySetupId); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(AlternateBodySetupId, result.SetupId); + } + + [Fact] + public void TryCompose_BothSetupSourcesZero_FallsBackToHumanSetupId() + { + ChargenGenderOptions gender = MakeGender() with { SetupId = 0u }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(bodySetupId: 0u); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); + } + + [Fact] + public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald() + { + ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: false)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + // tag 2 = non-bald eye ObjDesc, tag 20 = bald eye ObjDesc. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u); + } + + [Fact] + public void TryCompose_EyeStripSelected_UsesBaldObjDesc_WhenHairStyleIsBald() + { + ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: true)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u); // tag 20, bald. + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u); // tag 2, non-bald. + } + + [Fact] + public void TryCompose_NoseAndMouthStripsSelected_AppendBothObjDescs() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { NoseStrip = 0u, MouthStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 3); // nose tag. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 4); // mouth tag. + } + + [Fact] + public void TryCompose_AllFourClothingSlotsSelected_AppearInRetailOrderHeadgearTrousersShirtFootwear() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + TrousersStyle = 0u, + ShirtStyle = 0u, + FootwearStyle = 0u, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + // Only the base body's own tag (PartIndex 0) and the four clothing + // slots' PartIndex-5 overrides are present (no hair style/strips + // selected) — asserting the full ordered sequence pins retail's + // Headgear → Trousers → Shirt → Footwear append order directly. + uint[] expectedPartIds = + [ + 0x0100_0000u, // base body tag. + 0x0100_5000u + HeadgearClothingTableId, + 0x0100_5000u + TrousersClothingTableId, + 0x0100_5000u + ShirtClothingTableId, + 0x0100_5000u + FootwearClothingTableId, + ]; + Assert.Equal(expectedPartIds, result.ObjDesc.AnimPartChanges.Select(c => c.PartId).ToArray()); + } + + [Fact] + public void TryCompose_ClothingSlotWithColor_EmitsPartTextureAndDyeSubpalette() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, // gender.ClothingColors[0] = 7u == the fixture's palette-template key. + HeadgearShade = 0.0, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId); + Assert.Contains(result.ObjDesc.TextureChanges, c => c.PartIndex == 5 && c.NewTextureId == 0x0500_6000u); + // Real range (80, 16) packed by /8 => (10, 2). + Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + [Fact] + public void TryCompose_ClothingSlotWithoutColor_SkipsDyeSubpaletteButKeepsPartTextureChanges() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId); + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + [Fact] + public void TryCompose_UnknownClothingTableId_IsRecordedAsMissingAndSkipped() + { + ChargenGenderOptions gender = MakeGender(); + gender = gender with + { + Headgears = [new ChargenGearOption("Missing", 0x1900_00FFu, 0x3000_0099u)], + }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(0x1900_00FFu, result.MissingClothingTableIds); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5); + } + + [Fact] + public void TryCompose_UnknownHairColorPalSetId_IsRecordedAsMissingAndSkipped() + { + ChargenGenderOptions gender = MakeGender() with { HairColors = [0x0F00_00FFu] }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 0.5 }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(0x0F00_00FFu, result.MissingPalSetIds); + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 24); + } + + [Fact] + public void TryCompose_BodySetupAbsentFromClothingBaseEffects_IsRecordedButDoesNotThrow() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(bodySetupId: 0x0200_DEADu); // different from the resolved body setup. + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(HeadgearClothingTableId, result.ClothingTablesMissingBaseEffectForSetup); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5); + } + + [Fact] + public void TryCompose_OutOfRangeStyleIndex_IsTreatedAsUnselected() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 999u, EyesStrip = 999u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 2); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs new file mode 100644 index 00000000..ba284e91 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs @@ -0,0 +1,63 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Pins against the exact +/// formula ACE's PaletteSet.GetPaletteID cites as "Taken from +/// acclient.c (PalSet::GetPaletteID)": (int)((count - 0.000001) * shade), +/// clamped to [0, count-1], with an out-of-[0,1] shade (or a +/// non-positive count) returning -1. +/// +public class ChargenPalSetMathTests +{ + [Theory] + [InlineData(5, 0.0, 0)] + [InlineData(5, 1.0, 4)] + [InlineData(5, 0.5, 2)] + [InlineData(1, 0.0, 0)] + [InlineData(1, 1.0, 0)] + public void GetPaletteIndex_matches_the_cited_acclient_formula(int count, double shade, int expected) + { + Assert.Equal(expected, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Theory] + [InlineData(0, 0.5)] + [InlineData(-1, 0.5)] + public void GetPaletteIndex_returns_negative_one_for_non_positive_count(int count, double shade) + { + Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Theory] + [InlineData(5, -0.0001)] + [InlineData(5, 1.0001)] + [InlineData(5, ChargenAppearanceSelection.UnsetShade)] // retail's own "unset" sentinel is out of [0,1]. + public void GetPaletteIndex_returns_negative_one_for_out_of_range_shade(int count, double shade) + { + Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Fact] + public void GetPaletteIndex_never_exceeds_count_minus_one_near_the_upper_bound() + { + // shade == 1.0 exactly must land on the LAST index, not overflow past it — + // the (count - 0.000001) fudge factor exists precisely to guarantee this. + for (int count = 1; count <= 64; count++) + Assert.Equal(count - 1, ChargenPalSetMath.GetPaletteIndex(count, 1.0)); + } + + [Fact] + public void GetPaletteIndex_is_monotonic_non_decreasing_in_shade() + { + const int count = 13; + int previous = -1; + for (double shade = 0.0; shade <= 1.0; shade += 0.01) + { + int index = ChargenPalSetMath.GetPaletteIndex(count, shade); + Assert.True(index >= previous, $"index regressed at shade={shade}"); + previous = index; + } + } +} From 0e71d3b829faf7e660596d3fc24b61bee856a1c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 17:45:51 +0200 Subject: [PATCH 088/138] =?UTF-8?q?feat(app):=20Campaign=20CC=20slice=20CC?= =?UTF-8?q?4=20=E2=80=94=20chargen=20screen=20shell=20+=20Heritage/Profess?= =?UTF-8?q?ion/Skills/Town=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via CharacterCreationUiController/CharacterCreationUiMountCoordinator, cloning CharacterManagementUiController's recipe. Master shell ports SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random nav) verbatim, with free tab navigation over all six pages. Heritage, Profession, Skills, and Town pages bind to CC3's RuntimeCharacterCreationState commands; Appearance and Summary mount as content-inert placeholders for CC6b/CC5. Live-DAT probing (CharacterCreationLiveDatTests) found two widget- mapping surprises the decomp's DynamicCast hints don't predict: the Profession slider's value field imports as an editable UiField (wired for direct numeric entry), and the avail/health/stamina/mana/credits displays author as UIElement_Button hosts whose Type-12 value child is swallowed by UiButton.ConsumesDatChildren — substituted with the button's own Label. No new DatWidgetFactory widget types were needed. Threads the installed DAT's real ChargenOptions into Runtime via the new RuntimeCharacterCreationState.InstallOptions, called from ContentEffectsAudioCompositionPhase.Compose (mirrors InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/ ApplyCreationFailed) to SessionStatusWriter for both graphical and headless hosts, and adds the CharacterCreation view/command seam through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands alongside CharacterSelection's existing shape. Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender- select interim default, the omitted ToD-account gate, the button-Label widget substitution, the Random-button approximation, the flat-listbox Skills simplification, and the Appearance/Summary placeholders. Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless 165/0 unaffected, full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 12 +- .../2026-08-15-character-creation-campaign.md | 2 +- .../ContentEffectsAudioComposition.cs | 30 + .../InteractionRetainedUiComposition.cs | 31 + .../InteractionUiRuntimeSources.cs | 61 ++ .../Net/LiveSessionRuntimeFactory.cs | 15 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 206 +++++ src/AcDream.App/RuntimeOptions.cs | 8 + .../Layout/CharacterCreationHeritagePage.cs | 201 +++++ .../Layout/CharacterCreationProfessionPage.cs | 264 ++++++ .../UI/Layout/CharacterCreationSkillsPage.cs | 210 +++++ .../UI/Layout/CharacterCreationTownPage.cs | 121 +++ .../Layout/CharacterCreationUiController.cs | 650 ++++++++++++++ .../CharacterCreationUiMountCoordinator.cs | 99 +++ .../UI/Layout/ItemAppraisalTextFormatter.cs | 5 +- src/AcDream.App/UI/RetailUiRuntime.cs | 93 +- .../Hosting/HeadlessSessionHost.cs | 13 +- src/AcDream.Runtime/GameRuntime.cs | 2 + src/AcDream.Runtime/GameRuntimeViews.cs | 6 + .../Session/LiveSessionHost.cs | 15 +- .../Session/LiveSessionLifecycleHost.cs | 16 +- .../Session/RuntimeCharacterCreationState.cs | 35 +- .../ContentEffectsAudioCompositionTests.cs | 7 + .../Layout/CharacterCreationLiveDatTests.cs | 334 +++++++ .../CharacterCreationUiControllerTests.cs | 817 ++++++++++++++++++ .../RuntimeCharacterCreationStateTests.cs | 48 + .../Session/LiveSessionLifecycleHostTests.cs | 55 ++ 27 files changed, 3344 insertions(+), 12 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationUiController.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 913e52fc..df7934b4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 76 active rows (AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 79 active rows (AD-101..AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Heritage-page auto-gender-select interim default, the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -193,11 +193,14 @@ 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-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-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) | --- -## 3. Documented approximation (AP) — 147 active rows (AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 149 active rows (AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -387,9 +390,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | | 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-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) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-82 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) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -400,6 +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-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 2019c2ff..e2264c9f 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 | — | | | | +| 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. | | CC5 | — | | | | | CC6a | — | | | | | CC6b | — | | | | diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index c643ed07..5b6e72a3 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -12,9 +12,11 @@ using AcDream.Core.Physics; using AcDream.Core.Rendering; using AcDream.Core.Spells; using AcDream.Core.Vfx; +using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using DatReaderWriter; using Silk.NET.Input; @@ -63,6 +65,14 @@ internal sealed record ContentEffectsAudioDependencies( Action Error) { public RuntimeCharacterState Character => Runtime.CharacterOwner; + + /// Campaign CC slice CC4: the character-creation options + /// install target — see ContentEffectsAudioCompositionPhase.Compose's + /// ChargenOptionsInstalled step and + /// 's own doc + /// for why this is safe at composition time (strictly before any + /// session's Begin). + public LiveSessionController Session => Runtime.Session; } internal interface IGameWindowContentEffectsAudioPublication @@ -96,6 +106,13 @@ internal interface IContentEffectsAudioCompositionFactory RuntimeCharacterState character, MagicCatalog catalog); int GetSpellCount(MagicCatalog catalog); + /// Campaign CC slice CC4: mirrors the + /// / + /// pair's "load off dats, install once onto the owning Runtime state" + /// shape for the chargen options + /// (AcDream.Content.CharGen.ChargenTableReader.Load). + ChargenOptions LoadChargenOptions(IDatReaderWriter dats); + void InstallChargenOptions(LiveSessionController session, ChargenOptions options); IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, @@ -166,6 +183,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count; + public ChargenOptions LoadChargenOptions(IDatReaderWriter dats) => + AcDream.Content.CharGen.ChargenTableReader.Load(dats); + + public void InstallChargenOptions(LiveSessionController session, ChargenOptions options) => + session.CharacterCreationState.InstallOptions(options); + public IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, @@ -270,6 +293,7 @@ internal enum ContentEffectsAudioCompositionPoint PreparedAssetSourcePublished, MagicCatalogPublished, SpellMetadataInstalled, + ChargenOptionsInstalled, AnimationLoaderPublished, CollisionBuilderPublished, EmitterRegistryPublished, @@ -362,6 +386,12 @@ internal sealed class ContentEffectsAudioCompositionPhase : $"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat"); Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled); + ChargenOptions chargen = _factory.LoadChargenOptions(dats); + _factory.InstallChargenOptions(_dependencies.Session, chargen); + _dependencies.Log( + $"chargen: loaded {chargen.HeritagesById.Count} heritage(s) from portal.dat"); + Fault(ContentEffectsAudioCompositionPoint.ChargenOptionsInstalled); + IAnimationLoader animations = _factory.CreateAnimationLoader( dats, _dependencies.ResidencyBudgets.AnimationBytes, diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 6673cdf9..e1b93544 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -962,6 +962,37 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // /GameplayWindowCommands wrap this same d.Window.Close // delegate) — no separate exit path. d.Window.Close) + : null, + // Campaign CC slice CC4: same late-bound generation-capturing + // seam as CharacterSelection above. RequestExit here is a + // plain presentation action (closing the chargen screen and + // letting character-management's own Tick keep re-drawing + // itself underneath — see CharacterCreationUiController's + // OnExit doc), NOT a Runtime command or a window-close. + CharacterCreation: d.Options.LiveCharacterSelector is null + ? new CharacterCreationRuntimeBindings( + () => late.GameRuntime.CharacterCreation, + late.GameRuntime.CharacterCreationSelectHeritage, + late.GameRuntime.CharacterCreationSelectGender, + late.GameRuntime.CharacterCreationSelectTemplate, + late.GameRuntime.CharacterCreationSetAttribute, + late.GameRuntime.CharacterCreationSetAttributeLock, + late.GameRuntime.CharacterCreationTrainSkill, + late.GameRuntime.CharacterCreationSpecializeSkill, + late.GameRuntime.CharacterCreationUntrainSkill, + late.GameRuntime.CharacterCreationSelectStartArea, + late.GameRuntime.CharacterCreationFinish, + RequestExit: () => { }, + ResolveText: key => + { + lock (d.DatLock) + { + return new DatStringResolver(d.Dats).Resolve( + 0x23000002u, + DatStringResolver.ComputeHash(key)); + } + }, + OpenOnStart: d.Options.OpenCharacterCreationOnStart) : null); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index d7ddb795..8ea0680c 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -56,6 +56,19 @@ internal sealed class DeferredGameRuntimeStateCommands } } + /// Campaign CC slice CC4: same late-bound borrow shape as + /// . + public IRuntimeCharacterCreationView? CharacterCreation + { + get + { + lock (_gate) + return !_deactivated && _view is not null + ? _view.CharacterCreation + : null; + } + } + public IDisposable Bind( IGameRuntimeView view, IGameRuntimeCommands commands) @@ -166,6 +179,54 @@ internal sealed class DeferredGameRuntimeStateCommands Invoke((commands, generation) => commands.CharacterSelection.Cancel(generation)); + // ── Campaign CC slice CC4: character-creation page commands ───────── + // Same "capture view+commands under one generation" shape as every + // character-selection method above. + + public RuntimeCommandResult CharacterCreationSelectHeritage(uint heritageId) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectHeritage(generation, heritageId)); + + public RuntimeCommandResult CharacterCreationSelectGender(uint genderKey) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectGender(generation, genderKey)); + + public RuntimeCommandResult CharacterCreationSelectTemplate(uint templateIndex) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectTemplate(generation, templateIndex)); + + public RuntimeCommandResult CharacterCreationSetAttribute( + ChargenAttributeId attributeId, + int value) => + Invoke((commands, generation) => + commands.CharacterCreation.SetAttribute(generation, attributeId, value)); + + public RuntimeCommandResult CharacterCreationSetAttributeLock( + ChargenAttributeId attributeId, + bool locked) => + Invoke((commands, generation) => + commands.CharacterCreation.SetAttributeLock(generation, attributeId, locked)); + + public RuntimeCommandResult CharacterCreationTrainSkill(uint skillId) => + Invoke((commands, generation) => + commands.CharacterCreation.TrainSkill(generation, skillId)); + + public RuntimeCommandResult CharacterCreationSpecializeSkill(uint skillId) => + Invoke((commands, generation) => + commands.CharacterCreation.SpecializeSkill(generation, skillId)); + + public RuntimeCommandResult CharacterCreationUntrainSkill(uint skillId) => + Invoke((commands, generation) => + commands.CharacterCreation.UntrainSkill(generation, skillId)); + + public RuntimeCommandResult CharacterCreationSelectStartArea(int startAreaIndex) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectStartArea(generation, startAreaIndex)); + + public RuntimeCommandResult CharacterCreationFinish(bool confirmUnspentCredits) => + Invoke((commands, generation) => + commands.CharacterCreation.Finish(generation, confirmUnspentCredits)); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 8a9e3886..6af15653 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -221,7 +221,20 @@ internal sealed class LiveSessionRuntimeFactory _sessionId, selection.CharacterId, selection.CharacterName), - LoginCommands: loginCommands), + LoginCommands: loginCommands, + // Campaign CC slice CC4: the two sibling events to Roster/ + // CharacterEntered above — see SessionStatusWriter's own doc + // for why characterCreated precedes an eventual enteredWorld + // rather than replacing it. + CharacterCreated: identity => _statusWriter.CharacterCreated( + _sessionId, + identity.Guid, + identity.Name), + CreationFailed: rejection => _statusWriter.CreationFailed( + _sessionId, + rejection.RawCode, + rejection.Reason, + rejection.AttemptedName)), connectOptions); } diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 930e8412..cf6b8d14 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -1,5 +1,6 @@ using AcDream.App.Interaction; using AcDream.App.Net; +using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Runtime.World; @@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter private readonly GameRuntime _runtime; private readonly CurrentGameRuntimeCommandAdapter _commands; private readonly CharacterSelectionProjection _characterSelection; + private readonly CharacterCreationProjection _characterCreation; private readonly IDisposable _hostLease; private readonly object _subscriptionGate = new(); private readonly HashSet _subscriptions = []; @@ -42,6 +44,7 @@ internal sealed class CurrentGameRuntimeAdapter try { _characterSelection = new CharacterSelectionProjection(this); + _characterCreation = new CharacterCreationProjection(this); _commands = new CurrentGameRuntimeCommandAdapter( runtime.Session, sessionHost, @@ -93,6 +96,8 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeChatView Chat => _runtime.Chat; public IRuntimeCharacterSelectionView CharacterSelection => _characterSelection; + public IRuntimeCharacterCreationView CharacterCreation => + _characterCreation; public IRuntimeFellowshipView Fellowship => _runtime.Fellowship; public IRuntimeAllegianceView Allegiance => _runtime.Allegiance; public IRuntimeActionView Actions => _runtime.Actions; @@ -105,6 +110,10 @@ internal sealed class CurrentGameRuntimeAdapter _characterSelection; IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => _characterSelection; + public IRuntimeCharacterCreationCommands CharacterCreationCommands => + _characterCreation; + IRuntimeCharacterCreationCommands IGameRuntimeCommands.CharacterCreation => + _characterCreation; public IRuntimeSelectionCommands Selection => _commands; public IRuntimeCombatCommands Combat => _commands; public IRuntimeMagicCommands Magic => _commands; @@ -281,6 +290,83 @@ internal sealed class CurrentGameRuntimeAdapter } } + // ── Campaign CC slice CC4: character creation, same shape as the + // character-selection block above. ──────────────────────────────── + + private RuntimeCharacterCreationSnapshot CharacterCreationSnapshot() + { + lock (_subscriptionGate) + { + if (IsActive) + return _runtime.CharacterCreation.Snapshot; + return default; + } + } + + private ChargenSkillAdvancementClass CharacterCreationSkillLevel(uint skillId) + { + lock (_subscriptionGate) + { + return IsActive + ? _runtime.CharacterCreation.GetSkillLevel(skillId) + : ChargenSkillAdvancementClass.Inactive; + } + } + + private ChargenOptions CharacterCreationOptions() + { + lock (_subscriptionGate) + { + return IsActive + ? _runtime.CharacterCreation.Options + : ChargenOptions.Empty; + } + } + + private IDisposable SubscribeCharacterCreation( + IRuntimeCharacterCreationObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_subscriptionGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var gated = new AdapterCharacterCreationObserver(this, observer); + IDisposable runtimeSubscription = + _runtime.CharacterCreation.Subscribe(gated); + var subscription = new AdapterSubscription( + this, + runtimeSubscription); + _subscriptions.Add(subscription); + return subscription; + } + } + + private RuntimeCommandResult ExecuteCharacterCreation( + Func execute) + { + lock (_subscriptionGate) + { + if (!IsActive) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + _runtime.Generation); + } + return execute(_runtime.Session); + } + } + + private void ForwardCharacterCreation( + IRuntimeCharacterCreationObserver observer, + in RuntimeCharacterCreationDelta delta) + { + lock (_subscriptionGate) + { + if (IsActive) + observer.OnCharacterCreationChanged(in delta); + } + } + private sealed class CharacterSelectionProjection( CurrentGameRuntimeAdapter owner) : IRuntimeCharacterSelectionView, @@ -350,6 +436,126 @@ internal sealed class CurrentGameRuntimeAdapter owner.ForwardCharacterSelection(observer, in delta); } + private sealed class CharacterCreationProjection( + CurrentGameRuntimeAdapter owner) + : IRuntimeCharacterCreationView, + IRuntimeCharacterCreationCommands + { + public RuntimeCharacterCreationSnapshot Snapshot => + owner.CharacterCreationSnapshot(); + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + owner.CharacterCreationSkillLevel(skillId); + + public ChargenOptions Options => owner.CharacterCreationOptions(); + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => + owner.SubscribeCharacterCreation(observer); + + public RuntimeCommandResult SelectHeritage( + RuntimeGenerationToken expectedGeneration, + uint heritageId) => + owner.ExecuteCharacterCreation( + commands => commands.SelectHeritage(expectedGeneration, heritageId)); + + public RuntimeCommandResult SelectGender( + RuntimeGenerationToken expectedGeneration, + uint genderKey) => + owner.ExecuteCharacterCreation( + commands => commands.SelectGender(expectedGeneration, genderKey)); + + public RuntimeCommandResult SelectTemplate( + RuntimeGenerationToken expectedGeneration, + uint templateIndex) => + owner.ExecuteCharacterCreation( + commands => commands.SelectTemplate(expectedGeneration, templateIndex)); + + public RuntimeCommandResult SetAttribute( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + int value) => + owner.ExecuteCharacterCreation( + commands => commands.SetAttribute(expectedGeneration, attributeId, value)); + + public RuntimeCommandResult SetAttributeLock( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + bool locked) => + owner.ExecuteCharacterCreation( + commands => commands.SetAttributeLock(expectedGeneration, attributeId, locked)); + + public RuntimeCommandResult TrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) => + owner.ExecuteCharacterCreation( + commands => commands.TrainSkill(expectedGeneration, skillId)); + + public RuntimeCommandResult SpecializeSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) => + owner.ExecuteCharacterCreation( + commands => commands.SpecializeSkill(expectedGeneration, skillId)); + + public RuntimeCommandResult UntrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) => + owner.ExecuteCharacterCreation( + commands => commands.UntrainSkill(expectedGeneration, skillId)); + + public RuntimeCommandResult SetAppearanceIndex( + RuntimeGenerationToken expectedGeneration, + ChargenAppearanceSlot slot, + uint index) => + owner.ExecuteCharacterCreation( + commands => commands.SetAppearanceIndex(expectedGeneration, slot, index)); + + public RuntimeCommandResult SetShade( + RuntimeGenerationToken expectedGeneration, + ChargenShadeSlot slot, + double value) => + owner.ExecuteCharacterCreation( + commands => commands.SetShade(expectedGeneration, slot, value)); + + public RuntimeCommandResult SelectStartArea( + RuntimeGenerationToken expectedGeneration, + int startAreaIndex) => + owner.ExecuteCharacterCreation( + commands => commands.SelectStartArea(expectedGeneration, startAreaIndex)); + + public RuntimeCommandResult SetName( + RuntimeGenerationToken expectedGeneration, + string name) => + owner.ExecuteCharacterCreation( + commands => commands.SetName(expectedGeneration, name)); + + public RuntimeCommandResult SetSlot( + RuntimeGenerationToken expectedGeneration, + uint slot) => + owner.ExecuteCharacterCreation( + commands => commands.SetSlot(expectedGeneration, slot)); + + public RuntimeCommandResult Finish( + RuntimeGenerationToken expectedGeneration, + bool confirmUnspentCredits = false) => + owner.ExecuteCharacterCreation( + commands => commands.Finish(expectedGeneration, confirmUnspentCredits)); + + public RuntimeCommandResult AcknowledgeRejection( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.AcknowledgeRejection(expectedGeneration)); + } + + private sealed class AdapterCharacterCreationObserver( + CurrentGameRuntimeAdapter owner, + IRuntimeCharacterCreationObserver observer) + : IRuntimeCharacterCreationObserver + { + public void OnCharacterCreationChanged( + in RuntimeCharacterCreationDelta delta) => + owner.ForwardCharacterCreation(observer, in delta); + } + private sealed class AdapterSubscription( CurrentGameRuntimeAdapter owner, IDisposable runtimeSubscription) : IDisposable diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 155cd8ba..1d69d3e9 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -53,6 +53,12 @@ public sealed record RuntimeOptions( bool DumpClothing, int? LegacyStreamRadius, bool RetailUi, + /// Campaign CC slice CC4: interim env/test-only seam that opens + /// the character-creation screen once Runtime's chargen view goes + /// active — the real transition is retail's Create Character button + /// (0x100003A0), which stays ghosted until CC7's closing move. + /// See CharacterCreationRuntimeBindings.OpenOnStart. + bool OpenCharacterCreationOnStart, string? AcDir, bool UiProbeDump, string? UiProbeScript, @@ -146,6 +152,8 @@ public sealed record RuntimeOptions( // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + OpenCharacterCreationOnStart: + IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs new file mode 100644 index 00000000..e9274c71 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -0,0 +1,201 @@ +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Heritage page (gmCGHeritagePage, root 0x100003d1) — 13 +/// race buttons and the composed description text. Decomp anchors: +/// gmCGHeritagePage::InitializePage @ 0x00483a10 (button ids), +/// gmCGHeritagePage::ListenToElementMessage @ 0x00483860 (the exact +/// button-id -> heritage-id map), gmCGHeritagePage::Update @ +/// 0x00483210 (description text composition). +/// +internal sealed class CharacterCreationHeritagePage : IDisposable +{ + /// + /// Button element id -> CharGenState::SetHeritageGroup argument, + /// read verbatim off gmCGHeritagePage::ListenToElementMessage @ + /// 0x00483860's per-case literal (NOT the button element ids' + /// numeric order — e.g. 0x100005e8 maps to heritage 7/Tumerok, not to + /// its own position among the 13 ids). + /// + private static readonly IReadOnlyDictionary HeritageByButtonId = + new Dictionary + { + [0x100003BFu] = (uint)ChargenHeritageGroup.Aluvian, + [0x100003C1u] = (uint)ChargenHeritageGroup.Gharundim, + [0x100003C2u] = (uint)ChargenHeritageGroup.Sho, + // Retail gates this button (Viamontian) behind + // AccountHasThroneOfDestiny (MakeToDWarningDialog otherwise, + // @0x004838e5) — acdream has no account/DLC-ownership signal + // anywhere in ChargenOptions, so this ships without the gate + // (register AD-102, same row as the Town page's Sanamar gate). + [0x100003C3u] = (uint)ChargenHeritageGroup.Viamontian, + [0x10000590u] = (uint)ChargenHeritageGroup.Shadowbound, + [0x100005A9u] = (uint)ChargenHeritageGroup.Gearknight, + [0x100005E8u] = (uint)ChargenHeritageGroup.Tumerok, + [0x100005F1u] = (uint)ChargenHeritageGroup.Lugian, + [0x100005C4u] = (uint)ChargenHeritageGroup.Empyrean, + [0x10000591u] = (uint)ChargenHeritageGroup.Penumbraen, + [0x100005BFu] = (uint)ChargenHeritageGroup.Undead, + [0x100005C7u] = (uint)ChargenHeritageGroup.Olthoi, + [0x100005C8u] = (uint)ChargenHeritageGroup.OlthoiAcid, + }; + + /// + /// ID_CharGen_<Abbrev>Text_BonusSkills_Trained per + /// gmCGHeritagePage::Update's heritage switch (@0x004833e3): + /// Shadowbound and Penumbraen share the SAME string + /// (case 5: case 0xa:, both resolve "ShadText"). Lugian/Olthoi/ + /// OlthoiAcid have no matching string in the retail string table (the + /// decompiled switch's cases 8/0xc/0xd resolve to a vtable-slot + /// artifact instead of a string literal, and no + /// "ID_CharGen_Lug*"/"ID_CharGen_Olthoi*" key exists anywhere in the + /// named-retail dump) — those three heritages simply show the shared + /// header text with no per-heritage bonus-skills line, which is + /// retail's own real behavior here, not an acdream gap. + /// + private static readonly IReadOnlyDictionary BonusSkillsKeyByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = "ID_CharGen_AluvianText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Gharundim] = "ID_CharGen_GaruText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Sho] = "ID_CharGen_ShoText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Viamontian] = "ID_CharGen_ViaText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Shadowbound] = "ID_CharGen_ShadText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Penumbraen] = "ID_CharGen_ShadText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Gearknight] = "ID_CharGen_GearText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Tumerok] = "ID_CharGen_AunTText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Empyrean] = "ID_CharGen_EmpText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Undead] = "ID_CharGen_UndText_BonusSkills_Trained", + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Dictionary _buttons = []; + private readonly UiText? _description; + private bool _disposed; + + internal CharacterCreationHeritagePage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + foreach ((uint buttonId, uint heritageId) in HeritageByButtonId) + { + if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) + continue; + _buttons[button] = heritageId; + button.OnClick = () => Select(heritageId); + } + + _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + foreach ((UiButton button, uint heritageId) in _buttons) + button.Selected = heritageId == snapshot.HeritageId; + + if (_description is null) + return; + + string composed = ComposeDescription(view, snapshot.HeritageId, _bindings.ResolveText); + _description.LinesProvider = () => + [new UiText.Line(composed, _description.DefaultColor)]; + } + + internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) + { + // CharGenState::RandomizeHeritageGroup has no CC3 primitive — the + // nearest faithful approximation available from this page's own + // command surface is a uniform pick over every DAT-installed + // heritage (register AP-212 alongside the Skills/Summary Random + // gaps this same finding covers). + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null || view.Options.HeritagesById.Count == 0) + return; + uint[] ids = [.. view.Options.HeritagesById.Keys]; + uint chosen = ids[Random.Shared.Next(ids.Length)]; + Select(chosen); + } + + private void Select(uint heritageId) + { + if (_disposed) + return; + RuntimeCommandResult result = _bindings.SelectHeritage(heritageId); + if (!result.Accepted) + return; + + // CC4 interim default (register AD-101): the Profession/Skills/Town + // pages this slice builds need heritage+gender both selected + // (RuntimeCharacterCreationState.TrySelectTemplate's gate), but + // gender selection lives on the Appearance page (0x100003a7/a8), + // which stays an inert placeholder until CC6b. Auto-select the + // heritage's first available gender so those pages remain usable; + // CC6b's real gender buttons supersede this and the row retires + // then. + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is not null + && view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + && heritage.GendersByKey.Count > 0) + { + int genderKey = heritage.GendersByKey.Keys.Min(); + _bindings.SelectGender((uint)genderKey); + } + } + + /// + /// Ports gmCGHeritagePage::Update @ 0x00483210's text + /// composition: the (heritage-independent) starting-skills header + + /// body, the bonus-skills header, then — only once a heritage is + /// selected — that heritage's own bonus-skills line (absent for + /// Lugian/Olthoi/OlthoiAcid; see ). + /// is the DAT string lookup + /// (RetailUiRuntime's DatStringResolver over table + /// 0x23000002) threaded through the bindings record; a missing + /// resolver or a missing key degrades to skipping that segment rather + /// than throwing. + /// + private static string ComposeDescription( + IRuntimeCharacterCreationView view, + uint heritageId, + Func? resolveText) + { + if (resolveText is null) + { + return view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named) + ? named.Name + : string.Empty; + } + + var parts = new List(); + if (resolveText("ID_CharGen_Heritage_StartingSkills_Header") is { } header) + parts.Add(header); + if (resolveText("ID_CharGen_Heritage_StartingSkills") is { } body) + parts.Add(body); + if (resolveText("ID_CharGen_Heritage_BonusSkills_Trained_Header") is { } bonusHeader) + parts.Add(bonusHeader); + if (heritageId != 0 + && BonusSkillsKeyByHeritage.TryGetValue(heritageId, out string? bonusKey) + && resolveText(bonusKey) is { } bonusBody) + { + parts.Add(bonusBody); + } + return string.Join("\n\n", parts); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _buttons.Keys) + button.OnClick = null; + _buttons.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs new file mode 100644 index 00000000..315003aa --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs @@ -0,0 +1,264 @@ +using System.Globalization; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Profession page (gmCGProfessionPage, root 0x100003d2) — +/// seven template buttons and the six attribute sliders. Decomp anchors: +/// gmCGProfessionPage::InitializePage @ 0x00482d50 (slider/display +/// element ids), gmCGProfessionPage::UpdateProfession @ 0x004821b0 +/// (template-index -> button-id map, cited on ChargenTemplate), +/// gmCGProfessionPage::UpdateAttributeValues @ 0x00482450 +/// (avail/health/stamina/mana display sourcing). +/// +internal sealed class CharacterCreationProfessionPage : IDisposable +{ + /// Template button id -> template index, verbatim off + /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case + /// button-highlight dispatch (also the doc comment on + /// ChargenTemplate): 0 is Custom/Adventurer, and the six preset + /// buttons do NOT sit in template-index order. + private static readonly IReadOnlyDictionary TemplateByButtonId = + new Dictionary + { + [0x100003D9u] = 0u, // Custom / Adventurer + [0x100003DAu] = 1u, // Bow Hunter + [0x100003DFu] = 2u, // Swashbuckler + [0x100003DBu] = 3u, // Life Caster + [0x100003DCu] = 4u, // War Caster (aka War Mage) + [0x100003DDu] = 5u, // Wayfarer + [0x100003DEu] = 6u, // Soldier + }; + + /// + /// Attribute id -> slider container element id, verbatim off + /// gmCGProfessionPage::InitializePage @ 0x00482d50: + /// m_tSliderArray[N].pAttribField = GetChildRecursive(this, + /// id) for N=1..6 against ids 0x100003e6, e7, e9, e8, ea, eb + /// — note the e8/e9 SWAP (id e9 is slider index 3/Quickness, id e8 is + /// slider index 4/Coordination), matching + /// 's own documented 3/4 swap. + /// + private static readonly IReadOnlyDictionary SliderContainerByAttribute = + new Dictionary + { + [ChargenAttributeId.Strength] = 0x100003E6u, + [ChargenAttributeId.Endurance] = 0x100003E7u, + [ChargenAttributeId.Coordination] = 0x100003E8u, + [ChargenAttributeId.Quickness] = 0x100003E9u, + [ChargenAttributeId.Focus] = 0x100003EAu, + [ChargenAttributeId.Self] = 0x100003EBu, + }; + + // Relative (within-container) child ids, same InitializePage loop: + // 0x100002ec = lock UIElement_Button, 0x100002ed = name UIElement_Text + // (left at its authored default — see the ctor comment), + // 0x100002ee = the UIElement_Scrollbar drag control, 0x100002ef = the + // value display. Live-DAT probe (CharacterCreationLiveDatTests): + // 0x100002ef imports as a UiField, not UiText — retail's + // NumberInputFilter (attached to the sibling name field in the decomp, + // @0x00482e36) authors the whole slider row's text sub-elements as + // editable-capable; acdream's factory maps that authored shape to + // UiField. This also lets the player type an exact value directly. + private const uint SliderLockRelativeId = 0x100002ECu; + private const uint SliderControlRelativeId = 0x100002EEu; + private const uint SliderValueRelativeId = 0x100002EFu; + + private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value); + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Dictionary _templateButtons = []; + private readonly Dictionary _sliders = []; + private readonly UiButton? _availableValue; + private readonly UiButton? _healthValue; + private readonly UiButton? _staminaValue; + private readonly UiButton? _manaValue; + private bool _disposed; + + internal CharacterCreationProfessionPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + + foreach ((uint buttonId, uint templateIndex) in TemplateByButtonId) + { + if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) + continue; + _templateButtons[button] = templateIndex; + button.OnClick = () => SelectTemplate(templateIndex); + } + + foreach ((ChargenAttributeId attribute, uint containerId) in SliderContainerByAttribute) + { + if (UiElement.FindDescendant(pageRoot, containerId) is not { } container) + continue; + + UiButton? lockButton = UiElement.FindDescendant(container, SliderLockRelativeId) as UiButton; + UiScrollbar? slider = UiElement.FindDescendant(container, SliderControlRelativeId) as UiScrollbar; + UiField? value = UiElement.FindDescendant(container, SliderValueRelativeId) as UiField; + + ChargenAttributeId capturedAttribute = attribute; + if (lockButton is not null) + { + lockButton.OnClick = () => ToggleLock(capturedAttribute); + } + if (slider is not null) + { + slider.Horizontal = true; + slider.ScalarChanged = scalar => SetAttributeFromScalar(capturedAttribute, scalar); + } + if (value is not null) + { + value.Editable = true; + value.CharacterFilter = char.IsAsciiDigit; + value.OnSubmit = text => SetAttributeFromText(capturedAttribute, text); + } + + _sliders[attribute] = new SliderWidgets(lockButton, slider, value); + } + + // Live-DAT probe (CharacterCreationLiveDatTests): every one of the + // four display containers (0x100003e2..e5) authors as a Button + // whose Type-12 value child (0x100002f1/0x100002f3) is swallowed by + // UiButton.ConsumesDatChildren — the same "consumed child -> use + // the button's own Label" substitution the Skills page's credits + // meter needed (see CharacterCreationSkillsPage's ctor comment). + // Retail's own DynamicCast(0xc) on the CHILD (not the container) + // still stands as ground truth for the container's ROLE; only + // acdream's widget-level addressability differs (register AD-103). + _availableValue = UiElement.FindDescendant(pageRoot, 0x100003E2u) as UiButton; + _healthValue = UiElement.FindDescendant(pageRoot, 0x100003E3u) as UiButton; + _staminaValue = UiElement.FindDescendant(pageRoot, 0x100003E4u) as UiButton; + _manaValue = UiElement.FindDescendant(pageRoot, 0x100003E5u) as UiButton; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + _ = view; + foreach ((UiButton button, uint templateIndex) in _templateButtons) + button.Selected = templateIndex == snapshot.Template; + + foreach ((ChargenAttributeId attribute, SliderWidgets widgets) in _sliders) + { + int value = GetAttribute(snapshot.Attributes, attribute); + float scalar = (value - ChargenAttributeMath.AttributeMin) + / (float)(ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin); + widgets.Slider?.SetScalarPosition(scalar); + widgets.Value?.SetText(value.ToString(CultureInfo.InvariantCulture)); + if (widgets.Lock is { } lockButton) + lockButton.Selected = snapshot.IsAttributeLocked(attribute); + } + + SetDisplay(_availableValue, snapshot.RemainingAttributeCredits); + int endurance = snapshot.Attributes.Endurance; + // gmCGProfessionPage::UpdateAttributeValues @ 0x00482450: Health and + // Stamina both read CharGenState::GetAttribute(state, 2) + // (Endurance); Mana reads attribute 6 (Self). The Health call + // alone passes through an FPU divide the decompiler elided + // (_ftol2 @ 0x0048262b with no visible operand) — well-established + // AC vitals convention (Health = floor(Endurance / 2), Stamina = + // Endurance 1:1) is used here; a byte-level x87 trace would be + // needed to pin the exact MSVC rounding mode if this ever needs + // tighter verification. + SetDisplay(_healthValue, endurance / 2); + SetDisplay(_staminaValue, endurance); + SetDisplay(_manaValue, snapshot.Attributes.Self); + } + + internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) + { + // CharGenState::RandomizeTemplate has no CC3 primitive — the + // nearest faithful approximation is a uniform pick over this + // heritage's own template list (register AP-212). + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null + || !view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage) + || heritage.Templates.Count == 0) + { + return; + } + SelectTemplate((uint)Random.Shared.Next(heritage.Templates.Count)); + } + + private static int GetAttribute(ChargenAttributeValues values, ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => values.Strength, + ChargenAttributeId.Endurance => values.Endurance, + ChargenAttributeId.Quickness => values.Quickness, + ChargenAttributeId.Coordination => values.Coordination, + ChargenAttributeId.Focus => values.Focus, + ChargenAttributeId.Self => values.Self, + _ => 0, + }; + + private static void SetDisplay(UiButton? display, int value) + { + if (display is null) + return; + display.Label = value.ToString(CultureInfo.InvariantCulture); + } + + private void SelectTemplate(uint templateIndex) + { + if (_disposed) + return; + _bindings.SelectTemplate(templateIndex); + } + + private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar) + { + if (_disposed) + return; + int value = ChargenAttributeMath.AttributeMin + + (int)MathF.Round( + scalar * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin), + MidpointRounding.AwayFromZero); + _bindings.SetAttribute(attribute, value); + } + + /// Direct numeric entry via the value field's NumberInputFilter + /// (retail @0x00482e36) — an unparsable/empty submission is a no-op + /// rather than clamping to a guessed default. + private void SetAttributeFromText(ChargenAttributeId attribute, string text) + { + if (_disposed) + return; + if (int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int value)) + _bindings.SetAttribute(attribute, value); + } + + private void ToggleLock(ChargenAttributeId attribute) + { + if (_disposed) + return; + RuntimeCharacterCreationSnapshot? snapshot = _bindings.View()?.Snapshot; + bool currentlyLocked = snapshot?.IsAttributeLocked(attribute) ?? false; + _bindings.SetAttributeLock(attribute, !currentlyLocked); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _templateButtons.Keys) + button.OnClick = null; + _templateButtons.Clear(); + foreach (SliderWidgets widgets in _sliders.Values) + { + if (widgets.Lock is { } lockButton) + lockButton.OnClick = null; + if (widgets.Slider is { } slider) + slider.ScalarChanged = null; + if (widgets.Value is { } valueField) + valueField.OnSubmit = null; + } + _sliders.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs new file mode 100644 index 00000000..8dcf2d43 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -0,0 +1,210 @@ +using System.Globalization; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Skills page (gmCGSkillsPage, root 0x100003d3) — +/// simplified to one flat listbox rather than retail's four-bucket sorted +/// insertion model (InsertEntrySorted/UpdateSkillEntry, +/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register +/// AP-213). Decomp +/// anchors: gmCGSkillsPage::InitializePage @ 0x00481dd0 (listbox +/// 0x100003f7, credits meter 0x100002f3 — imports as button +/// 0x100003f9's own consumed Label, see the ctor comment — info +/// panes 0x100003fb/0x100003fc), +/// gmCGSkillsPage::UpdateCreditsMeter +/// @ 0x004808f0 (credits display is the raw +/// remainingSkillCredits — no formula). The 16 skill ids uncostable +/// in BOTH the heritage's own list and the global SkillTable (retail's own +/// skills listbox never lists them either — CC1's +/// ChargenTableReaderInstalledDatTests) are filtered out via the +/// same two-tier presence check RuntimeCharacterCreationState's +/// TryGetSkillCost uses. +/// +internal sealed class CharacterCreationSkillsPage : IDisposable +{ + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiTemplateListBox? _list; + private readonly UiButton? _credits; + private readonly UiText? _infoTitle; + private readonly UiText? _infoText; + private readonly List _rows = []; + private readonly Dictionary _rowSkillIds = []; + private uint _lastHeritageId; + private bool _rowsBuilt; + private bool _disposed; + + internal CharacterCreationSkillsPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings, + Func templateResolver) + { + _bindings = bindings; + _list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox; + if (_list is not null) + _list.TemplateResolver = templateResolver; + // Live-DAT probe (CharacterCreationLiveDatTests): the credits meter + // (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw + // dat CHILD of button 0x100003f9, not as a standalone descendant of + // the page root. UiButton.ConsumesDatChildren swallows it before it + // becomes an addressable widget (the same reason UiMeter's overlay + // text needed an explicit carve-out in LayoutImporter) — the + // faithful substitute is the button's own Label, which is exactly + // the mechanism our factory already uses to surface a consumed + // Type-12 child's text (register AD-103). + _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; + _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; + _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId) + { + RebuildRows(view, snapshot.HeritageId); + _lastHeritageId = snapshot.HeritageId; + _rowsBuilt = true; + } + + foreach (UiButton row in _rows) + { + if (!_rowSkillIds.TryGetValue(row, out uint skillId)) + continue; + row.Label = FormatSkillLabel(view, snapshot.HeritageId, skillId); + } + + if (_credits is { } credits) + credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); + } + + private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) + { + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowSkillIds.Clear(); + _list?.Flush(); + + if (_list is null + || _list.Templates.Count == 0 + || _list.TemplateResolver is null + || !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + { + return; + } + + UiTemplateListEntry template = _list.Templates[0]; + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (!IsCostable(heritage, view.Options, skillId)) + continue; + if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId) + is not UiButton row) + { + continue; + } + + _list.AddPrebuiltRow(row); + row.Enabled = true; + row.SuppressSelfToggle = true; + uint capturedSkillId = skillId; + row.OnClick = () => Advance(capturedSkillId); + row.OnDoubleClick = () => Retreat(capturedSkillId); + _rows.Add(row); + _rowSkillIds[row] = skillId; + } + } + + /// Same dictionary-presence gate as + /// RuntimeCharacterCreationState.TryGetSkillCost — heritage list + /// first, global SkillTable fallback. + private static bool IsCostable( + ChargenHeritageOptions heritage, + ChargenOptions options, + uint skillId) => + heritage.SkillCostsBySkillId.ContainsKey(skillId) + || options.GlobalSkillCostsBySkillId.ContainsKey(skillId); + + private string FormatSkillLabel( + IRuntimeCharacterCreationView view, + uint heritageId, + uint skillId) + { + string name = ItemAppraisalTextFormatter.SkillName((int)skillId); + ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); + (int trainedCost, int specializedCost) = GetCosts(view, heritageId, skillId); + return string.Create( + CultureInfo.InvariantCulture, + $"{name}: {level} (T{trainedCost}/S{specializedCost})"); + } + + private static (int Trained, int Specialized) GetCosts( + IRuntimeCharacterCreationView view, + uint heritageId, + uint skillId) + { + if (view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + { + if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost)) + return (cost.NormalCost, cost.PrimaryCost); + } + if (view.Options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost global)) + return (global.NormalCost, global.PrimaryCost); + return (0, 0); + } + + /// OnClick: one step up (Untrained/Inactive -> Trained, + /// Trained -> Specialized). Simplified from retail's separate + /// Increase/Decrease affordances (IncreaseSkillLevel/ + /// DecreaseSkillLevel) to one click target per row. + private void Advance(uint skillId) + { + if (_disposed) + return; + ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId) + ?? ChargenSkillAdvancementClass.Inactive; + if (level is ChargenSkillAdvancementClass.Inactive or ChargenSkillAdvancementClass.Untrained) + _bindings.TrainSkill(skillId); + else if (level == ChargenSkillAdvancementClass.Trained) + _bindings.SpecializeSkill(skillId); + } + + /// OnDoubleClick: one step down (Specialized -> Trained, + /// Trained -> Untrained). + private void Retreat(uint skillId) + { + if (_disposed) + return; + ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId) + ?? ChargenSkillAdvancementClass.Inactive; + if (level == ChargenSkillAdvancementClass.Specialized) + _bindings.TrainSkill(skillId); + else if (level == ChargenSkillAdvancementClass.Trained) + _bindings.UntrainSkill(skillId); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowSkillIds.Clear(); + _list?.Flush(); + if (_list is not null) + _list.TemplateResolver = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs new file mode 100644 index 00000000..4aeadb51 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs @@ -0,0 +1,121 @@ +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Town page (gmCGTownPage, root 0x100003d5) — the four +/// starting-area buttons. Decomp anchors: +/// gmCGTownPage::InitializePage @ 0x0047c6d0 (button ids), +/// gmCGTownPage::SetTown @ 0x0047c360 (button -> +/// CharGenState::SetStartArea(arg2 - 1) literal index map: Holtburg +/// -> 0, Shoushi -> 1, Yaraq -> 2, Sanamar -> 3), +/// gmCGTownPage::ListenToElementMessage @ 0x0047c480 (Sanamar's +/// AccountHasThroneOfDestiny gate — acdream has no account/DLC +/// signal, so it ships without the gate; register AD-102, same row as the +/// Heritage page's Viamontian gate), gmCGTownPage::SetTownString @ +/// 0x0047c1f0 (composed description text). +/// +internal sealed class CharacterCreationTownPage : IDisposable +{ + /// Button element id -> the LITERAL startArea index + /// gmCGTownPage::SetTown sends — retail hardcodes these four + /// indices directly rather than looking them up by name, so this port + /// does too. + private static readonly IReadOnlyDictionary StartAreaByButtonId = + new Dictionary + { + [0x1000040Du] = 0, // Holtburg + [0x1000040Fu] = 1, // Shoushi + [0x1000040Eu] = 2, // Yaraq + [0x1000040Bu] = 3, // Sanamar (ToD-gated in retail; see class doc) + }; + + private static readonly IReadOnlyDictionary TownTextKeyByStartArea = + new Dictionary + { + [0] = "ID_CharGen_HoltText", + [1] = "ID_CharGen_ShoushiText", + [2] = "ID_CharGen_YaraqText", + [3] = "ID_CharGen_SanamarText", + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Dictionary _buttons = []; + private readonly UiText? _description; + private bool _disposed; + + internal CharacterCreationTownPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + foreach ((uint buttonId, int startArea) in StartAreaByButtonId) + { + if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) + continue; + _buttons[button] = startArea; + button.OnClick = () => Select(startArea); + } + + _description = UiElement.FindDescendant(pageRoot, 0x10000409u) as UiText; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + foreach ((UiButton button, int startArea) in _buttons) + button.Selected = startArea == snapshot.StartArea; + + if (_description is null) + return; + + string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText); + _description.LinesProvider = () => + [new UiText.Line(composed, _description.DefaultColor)]; + } + + internal void Randomize(IRuntimeCharacterCreationView view) + { + // CharGenState::SetStartArea(RandInt(hasToD ? 4 : 3)) — acdream + // always treats ToD as owned (see the class doc's AD-102 note), so + // this picks uniformly across all 4 literal indices (register + // AP-212 for the Random approximation itself), clamped to however + // many starter areas the installed DAT actually carries. + int bound = Math.Min(4, view.Options.StarterAreas.Count); + if (bound <= 0) + return; + Select(Random.Shared.Next(bound)); + } + + private void Select(int startArea) + { + if (_disposed) + return; + _bindings.SelectStartArea(startArea); + } + + private static string ComposeDescription(int startArea, Func? resolveText) + { + if (resolveText is null) + return string.Empty; + string? howTo = resolveText("ID_CharGen_TownHowTo"); + string? townText = TownTextKeyByStartArea.TryGetValue(startArea, out string? key) + ? resolveText(key) + : null; + if (howTo is null && townText is null) + return string.Empty; + return $"{howTo}\n\n{townText}\n"; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _buttons.Keys) + button.OnClick = null; + _buttons.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs new file mode 100644 index 00000000..0ef76c20 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -0,0 +1,650 @@ +using System.Numerics; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// Bindings the retail character-creation screen (gmCharGenMainUI) +/// needs beyond the borrowed view: generation-capturing command wrappers, +/// mirroring 's shape exactly. +/// Every Func here is a late-bound seam (Campaign CC — see +/// feedback_resolve_deferred_funcs_per_call.md): callers MUST resolve +/// it per-call, never capture the delegate once at mount time. +/// +/// Campaign CC slice CC4: the retail +/// transition is Create Character (0x100003A0) → +/// QueueUIMode(0x1000000b), but that button stays ghosted until CC7's +/// closing move. This flag is the interim env/test-only open seam +/// (ACDREAM_OPEN_CHARGEN=1) +/// so the screen can be exercised before the real button is wired. +public sealed record CharacterCreationRuntimeBindings( + Func View, + Func SelectHeritage, + Func SelectGender, + Func SelectTemplate, + Func SetAttribute, + Func SetAttributeLock, + Func TrainSkill, + Func SpecializeSkill, + Func UntrainSkill, + Func SelectStartArea, + Func Finish, + Action RequestExit, + /// DAT string lookup (table 0x23000002, the SAME table + /// every other ID_CharGen_*/ID_Character* key resolves + /// through) — used by the Heritage page's composed description text. + /// degrades to the heritage's own DAT + /// Name field instead of the full composed copy. + Func? ResolveText = null, + bool OpenOnStart = false); + +/// +/// Projects Runtime's borrowed +/// through retail gmCharGenMainUI's authored retained layout — the +/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/ +/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this +/// slice builds. The Appearance (0x100003d4) and Summary +/// (0x100003d6) page roots are mounted but content-inert — CC6/CC5 +/// fill them (register TS-82). +/// +/// +/// Decomp anchors: root construction + child resolution +/// gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0 (root element +/// 0x100003cc from enum 0x10000039); page switching +/// gmCharGenMainUI::SetProgressState @ 0x004e7a10 (the Olthoi +/// tab-hiding + redirect logic); nav dispatch +/// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450; exit +/// confirmation gmCharGenMainUI::DoExit @ 0x004e8650; randomize +/// dispatch gmCharGenMainUI::DoRandom @ 0x004e7d70. +/// +/// +internal sealed class CharacterCreationUiController : IDisposable +{ + internal const uint RootEnum = 0x10000039u; + internal const uint RootElementId = 0x100003CCu; + internal const uint ProgressBarElementId = 0x100003CEu; + internal const uint BackElementId = 0x100003C6u; + internal const uint NextElementId = 0x100003C7u; + internal const uint FinishElementId = 0x100003C8u; + internal const uint HelpElementId = 0x100003C9u; + internal const uint ExitElementId = 0x100003CAu; + internal const uint RandomElementId = 0x100003CBu; + internal const uint MasterPageElementId = 0x100003D0u; + internal const uint HeritagePageElementId = 0x100003D1u; + internal const uint ProfessionPageElementId = 0x100003D2u; + internal const uint SkillsPageElementId = 0x100003D3u; + internal const uint AppearancePageElementId = 0x100003D4u; + internal const uint TownPageElementId = 0x100003D5u; + internal const uint SummaryPageElementId = 0x100003D6u; + internal const uint HeritageTabElementId = 0x100003EFu; + internal const uint ProfessionTabElementId = 0x100003F0u; + internal const uint SkillsTabElementId = 0x100003F1u; + internal const uint AppearanceTabElementId = 0x100003F2u; + internal const uint TownTabElementId = 0x100003F3u; + internal const uint SummaryTabElementId = 0x100003F4u; + + /// Retail's gmCharGenMainUI::ECGProgress enum values — + /// used verbatim as the master page's per-page state ids + /// (0x10000025 + (page - 1)) and the tab-hide/redirect math in + /// . + internal enum Page + { + Heritage = 1, + Profession = 2, + Skills = 3, + Appearance = 4, + Town = 5, + Summary = 6, + } + + internal sealed record DialogStrings(string ExitWarning); + + private readonly UiRoot _host; + private readonly ImportedLayout _layout; + private readonly UiElement _progressBar; + private readonly UiButton _back; + private readonly UiButton _next; + private readonly UiButton _finish; + private readonly UiButton _help; + private readonly UiButton _exit; + private readonly UiButton _random; + private readonly UiElement _masterPage; + private readonly UiElement _heritagePageRoot; + private readonly UiElement _professionPageRoot; + private readonly UiElement _skillsPageRoot; + private readonly UiElement _appearancePageRoot; + private readonly UiElement _townPageRoot; + private readonly UiElement _summaryPageRoot; + private readonly UiButton _heritageTab; + private readonly UiButton _professionTab; + private readonly UiButton _skillsTab; + private readonly UiButton _appearanceTab; + private readonly UiButton _townTab; + private readonly UiButton _summaryTab; + private readonly RetailDialogFactory _dialogs; + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly DialogStrings _strings; + private readonly CharacterCreationHeritagePage _heritagePage; + private readonly CharacterCreationProfessionPage _professionPage; + private readonly CharacterCreationSkillsPage _skillsPage; + private readonly CharacterCreationTownPage _townPage; + + private Vector2 _authoredCanvas; + private RuntimeGenerationToken _lastGeneration; + private long _lastRevision = long.MinValue; + private Page _currentPage = Page.Heritage; + private bool _active; + private bool _isOpen; + private bool _openOnStartConsumed; + private uint _exitDialogContext; + private bool _suppressDialogCallbacks; + private bool _disposed; + + private CharacterCreationUiController( + UiRoot host, + ImportedLayout layout, + UiElement progressBar, + UiButton back, + UiButton next, + UiButton finish, + UiButton help, + UiButton exit, + UiButton random, + UiElement masterPage, + UiElement heritagePageRoot, + UiElement professionPageRoot, + UiElement skillsPageRoot, + UiElement appearancePageRoot, + UiElement townPageRoot, + UiElement summaryPageRoot, + UiButton heritageTab, + UiButton professionTab, + UiButton skillsTab, + UiButton appearanceTab, + UiButton townTab, + UiButton summaryTab, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterCreationRuntimeBindings bindings, + DialogStrings strings) + { + _host = host; + _layout = layout; + _progressBar = progressBar; + _back = back; + _next = next; + _finish = finish; + _help = help; + _exit = exit; + _random = random; + _masterPage = masterPage; + _heritagePageRoot = heritagePageRoot; + _professionPageRoot = professionPageRoot; + _skillsPageRoot = skillsPageRoot; + _appearancePageRoot = appearancePageRoot; + _townPageRoot = townPageRoot; + _summaryPageRoot = summaryPageRoot; + _heritageTab = heritageTab; + _professionTab = professionTab; + _skillsTab = skillsTab; + _appearanceTab = appearanceTab; + _townTab = townTab; + _summaryTab = summaryTab; + _dialogs = dialogs; + _bindings = bindings; + _strings = strings; + + Root.Left = 0f; + Root.Top = 0f; + 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. + _authoredCanvas = new Vector2( + Root.Width > 0f ? Root.Width : 800f, + Root.Height > 0f ? Root.Height : 600f); + + _heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings); + _professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings); + _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); + _townPage = new CharacterCreationTownPage(townPageRoot, bindings); + + // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. + _back.OnClick = OnBack; + _next.OnClick = OnNext; + // Finish (0x100003c8) stays ghosted this round: Summary + // (0x100003d6) is CC5's placeholder, and DoFinish's real gate + // sequence lives in RuntimeCharacterCreationState.TryBeginFinish — + // wiring the button here without a Summary page to confirm/collect + // the name would let a click reach the wire with an empty name and + // silently refuse. No OnClick handler; _finish.Enabled stays false + // (see ApplyProgressState). + _finish.OnClick = null; + // Help (0x100003c9) is not handled in gmCharGenMainUI's own + // ListenToElementMessage switch (case 0x100003c9 falls straight + // through to the base UIFramework handler) — retail has no custom + // help action here either; leave it a no-op. + _help.OnClick = null; + _exit.OnClick = OnExit; + _random.OnClick = OnRandom; + _heritageTab.OnClick = () => ApplyProgressState(Page.Heritage); + _professionTab.OnClick = () => ApplyProgressState(Page.Profession); + _skillsTab.OnClick = () => ApplyProgressState(Page.Skills); + _appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance); + _townTab.OnClick = () => ApplyProgressState(Page.Town); + _summaryTab.OnClick = () => ApplyProgressState(Page.Summary); + } + + internal UiElement Root => _layout.Root; + + internal static CharacterCreationUiController? CreateDetached( + UiRoot host, + ImportedLayout layout, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterCreationRuntimeBindings bindings, + DialogStrings strings) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(dialogs); + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(strings); + + if (layout.Root.DatElementId != RootElementId + || layout.FindElement(ProgressBarElementId) is not { } progressBar + || layout.FindElement(BackElementId) is not UiButton back + || layout.FindElement(NextElementId) is not UiButton next + || layout.FindElement(FinishElementId) is not UiButton finish + || layout.FindElement(HelpElementId) is not UiButton help + || layout.FindElement(ExitElementId) is not UiButton exit + || layout.FindElement(RandomElementId) is not UiButton random + || layout.FindElement(MasterPageElementId) is not { } masterPage + || layout.FindElement(HeritagePageElementId) is not { } heritagePageRoot + || layout.FindElement(ProfessionPageElementId) is not { } professionPageRoot + || layout.FindElement(SkillsPageElementId) is not { } skillsPageRoot + || layout.FindElement(AppearancePageElementId) is not { } appearancePageRoot + || layout.FindElement(TownPageElementId) is not { } townPageRoot + || layout.FindElement(SummaryPageElementId) is not { } summaryPageRoot + || layout.FindElement(HeritageTabElementId) is not UiButton heritageTab + || layout.FindElement(ProfessionTabElementId) is not UiButton professionTab + || layout.FindElement(SkillsTabElementId) is not UiButton skillsTab + || layout.FindElement(AppearanceTabElementId) is not UiButton appearanceTab + || layout.FindElement(TownTabElementId) is not UiButton townTab + || layout.FindElement(SummaryTabElementId) is not UiButton summaryTab) + { + Console.WriteLine( + "[UI] character creation: the authored root/master-shell contract is incomplete."); + return null; + } + + return new CharacterCreationUiController( + host, + layout, + progressBar, + back, + next, + finish, + help, + exit, + random, + masterPage, + heritagePageRoot, + professionPageRoot, + skillsPageRoot, + appearancePageRoot, + townPageRoot, + summaryPageRoot, + heritageTab, + professionTab, + skillsTab, + appearanceTab, + townTab, + summaryTab, + templateResolver, + dialogs, + bindings, + strings); + } + + internal void AttachAndTick() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Root.Parent is null) + _host.AddChild(Root); + Tick(); + } + + internal void Tick() + { + if (_disposed) + return; + + IRuntimeCharacterCreationView? view = _bindings.View(); + RuntimeCharacterCreationSnapshot snapshot = view?.Snapshot ?? default; + if (view is null || !snapshot.IsActive) + { + Deactivate(); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + return; + } + + if (!_active) + { + _active = true; + // CC4 interim open seam (ACDREAM_OPEN_CHARGEN=1) — the real + // Create-button transition is CC7's. Fires once per mount. + if (_bindings.OpenOnStart && !_openOnStartConsumed) + { + _openOnStartConsumed = true; + Open(); + } + } + + if (_isOpen) + { + Root.Visible = true; + _host.FixedCanvasSize = _authoredCanvas; + _host.BringToFront(Root); + } + else + { + Root.Visible = false; + } + + if (_lastGeneration != snapshot.Generation + || _lastRevision != snapshot.Revision) + { + _heritagePage.Refresh(view, snapshot); + _professionPage.Refresh(view, snapshot); + _skillsPage.Refresh(view, snapshot); + _townPage.Refresh(view, snapshot); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + } + + ReconcileDialogs(snapshot); + } + + /// Opens the screen at retail's authored default page + /// (gmCharGenMainUI::gmCharGenMainUI's trailing + /// SetProgressState(this, ECG_HERTAGE)). + internal void Open() + { + if (_disposed) + return; + _isOpen = true; + ApplyProgressState(Page.Heritage); + } + + private void Close() + { + _isOpen = false; + Root.Visible = false; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + CloseAllDialogs(suppressCallbacks: true); + } + finally + { + _back.OnClick = null; + _next.OnClick = null; + _finish.OnClick = null; + _help.OnClick = null; + _exit.OnClick = null; + _random.OnClick = null; + _heritageTab.OnClick = null; + _professionTab.OnClick = null; + _skillsTab.OnClick = null; + _appearanceTab.OnClick = null; + _townTab.OnClick = null; + _summaryTab.OnClick = null; + _heritagePage.Dispose(); + _professionPage.Dispose(); + _skillsPage.Dispose(); + _townPage.Dispose(); + _host.RemoveChild(Root); + } + } + + // ── Nav dispatch (gmCharGenMainUI::ListenToElementMessage @ 0x004e9450) ── + + private void OnBack() + { + if (_disposed) + return; + if (_currentPage <= Page.Heritage) + { + OnExit(); + return; + } + ApplyProgressState(_currentPage - 1); + } + + private void OnNext() + { + if (_disposed) + return; + if (_currentPage < Page.Summary) + ApplyProgressState(_currentPage + 1); + } + + private void OnExit() + { + if (_disposed) + return; + // gmCharGenMainUI::DoExit @ 0x004e8650's own guard: a second Exit + // click while the confirmation is already open is a no-op. + if (_exitDialogContext != 0u) + return; + + _exitDialogContext = _dialogs.MakeConfirmation( + _strings.ExitWarning, + data => + { + _exitDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + // RecvNotice_CloseDialog @ 0x004e9780's exit-context branch: + // confirm -> QueueUIMode(0x1000000a) (leave chargen). Our + // equivalent is closing this screen; whatever mounted the + // character-management screen already keeps re-drawing it + // underneath (this screen only BringToFront's itself while + // open — see Tick). + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + { + Close(); + _bindings.RequestExit(); + } + }); + } + + private void OnRandom() + { + if (_disposed) + return; + + // gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town + // are ported below; Skills' CharGenState::RandomizeSkills and the + // Summary randomize-warning dialog have no CC3 primitive/page yet + // this round — register AP-212 covers both gaps, and _random.Enabled + // already keeps the control ghosted on those pages (ApplyProgressState). + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + + switch (_currentPage) + { + case Page.Heritage: + _heritagePage.Randomize(snapshot); + break; + case Page.Profession: + _professionPage.Randomize(snapshot); + break; + case Page.Town: + _townPage.Randomize(view); + break; + } + } + + // ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ──── + + private void ApplyProgressState(Page target) + { + _heritagePageRoot.Visible = false; + _professionPageRoot.Visible = false; + _skillsPageRoot.Visible = false; + _appearancePageRoot.Visible = false; + _townPageRoot.Visible = false; + _summaryPageRoot.Visible = false; + _next.Visible = true; + _finish.Visible = false; + + Page previous = _currentPage; + _currentPage = target; + _heritageTab.Selected = false; + _professionTab.Selected = false; + _skillsTab.Selected = false; + _appearanceTab.Selected = false; + _townTab.Selected = false; + _summaryTab.Selected = false; + + uint heritageId = _bindings.View()?.Snapshot.HeritageId ?? 0u; + bool isOlthoi = heritageId == (uint)ChargenHeritageGroup.Olthoi + || heritageId == (uint)ChargenHeritageGroup.OlthoiAcid; + if (isOlthoi) + { + _professionTab.Visible = false; + _skillsTab.Visible = false; + _townTab.Visible = false; + if (_currentPage < previous) + { + if (_currentPage is Page.Profession or Page.Skills) + _currentPage = Page.Heritage; + else if (_currentPage == Page.Town) + _currentPage = Page.Appearance; + } + else + { + if (_currentPage is Page.Profession or Page.Skills) + _currentPage = Page.Appearance; + else if (_currentPage == Page.Town) + _currentPage = Page.Summary; + } + } + else + { + _professionTab.Visible = true; + _skillsTab.Visible = true; + _townTab.Visible = true; + } + + SetMasterPageState(0x10000025u + (uint)_currentPage - 1u); + switch (_currentPage) + { + case Page.Heritage: + _heritagePageRoot.Visible = true; + _heritageTab.Selected = true; + break; + case Page.Profession: + _professionPageRoot.Visible = true; + _professionTab.Selected = true; + break; + case Page.Skills: + _skillsPageRoot.Visible = true; + _skillsTab.Selected = true; + break; + case Page.Appearance: + _appearancePageRoot.Visible = true; + _appearanceTab.Selected = true; + break; + case Page.Town: + _townPageRoot.Visible = true; + _townTab.Selected = true; + break; + case Page.Summary: + _summaryPageRoot.Visible = true; + _summaryTab.Selected = true; + _next.Visible = false; + _finish.Visible = true; + break; + } + + // Random (0x100003cb): retail refuses on Skills (no + // RandomizeSkills primitive ported — AP-212) and on Summary + // (MakeRandomizeWarningDialog is CC5's); Appearance is this round's + // placeholder. + _random.Enabled = _currentPage + is not (Page.Skills or Page.Appearance or Page.Summary); + // Finish stays ghosted regardless of page — Summary is a + // placeholder this round (see the ctor comment on _finish.OnClick). + _finish.Enabled = false; + + _lastRevision = long.MinValue; + Tick(); + } + + private void SetMasterPageState(uint stateId) + { + if (_masterPage is IUiDatStateful stateful) + stateful.TrySetRetailState(stateId); + } + + private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot) + { + // Local-refusal / rejection surfacing is CC5's Summary-page job + // (the Finish gate only fires from that page). This round only + // needs the exit-confirmation dialog reconciled against disposal. + _ = snapshot; + } + + private void Deactivate() + { + if (_active) + { + _active = false; + _isOpen = false; + _openOnStartConsumed = false; + Root.Visible = false; + } + CloseAllDialogs(suppressCallbacks: true); + } + + private void CloseAllDialogs(bool suppressCallbacks) + { + bool previous = _suppressDialogCallbacks; + _suppressDialogCallbacks |= suppressCallbacks; + try + { + if (_exitDialogContext != 0u) + { + uint closing = _exitDialogContext; + _exitDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + } + finally + { + _suppressDialogCallbacks = previous; + } + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs new file mode 100644 index 00000000..a6d83b45 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs @@ -0,0 +1,99 @@ +namespace AcDream.App.UI.Layout; + +internal sealed record CharacterCreationUiMountResources( + uint LayoutId, + ImportedLayout Layout, + Func TemplateResolver, + CharacterCreationUiController.DialogStrings Strings); + +/// +/// Retryable, idempotent composition edge for the character-creation screen — +/// clone of 's recipe. DATs +/// can become readable after the graphical runtime starts, so an unavailable +/// dialog catalog, root, or string must not permanently suppress the screen. +/// +internal sealed class CharacterCreationUiMountCoordinator : IDisposable +{ + private readonly UiRoot _host; + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Func _ensureDialogs; + private readonly Func _loadResources; + private bool _disposed; + + public CharacterCreationUiMountCoordinator( + UiRoot host, + CharacterCreationRuntimeBindings bindings, + Func ensureDialogs, + Func loadResources) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + _ensureDialogs = ensureDialogs + ?? throw new ArgumentNullException(nameof(ensureDialogs)); + _loadResources = loadResources + ?? throw new ArgumentNullException(nameof(loadResources)); + } + + public CharacterCreationUiController? Controller { get; private set; } + + public void Tick() + { + if (_disposed || Controller is not null) + return; + + try + { + RetailDialogFactory? dialogs = _ensureDialogs(); + if (dialogs is null) + return; + + CharacterCreationUiMountResources? resources = _loadResources(); + if (resources is null) + return; + + CharacterCreationUiController? candidate = + CharacterCreationUiController.CreateDetached( + _host, + resources.Layout, + resources.TemplateResolver, + dialogs, + _bindings, + resources.Strings); + if (candidate is null) + return; + + Controller = candidate; + candidate.AttachAndTick(); + Console.WriteLine( + $"[UI] retail character creation from enum table 5 " + + $"(0x10000039 -> 0x{resources.LayoutId:X8}, root 0x100003CC)."); + } + catch (Exception error) + { + CharacterCreationUiController? partial = Controller; + Controller = null; + try + { + partial?.Dispose(); + } + catch (Exception cleanupError) + { + Console.WriteLine( + "[UI] character creation partial-mount cleanup failed: " + + cleanupError.Message); + } + Console.WriteLine( + "[UI] character creation mount will retry after resource " + + $"recovery: {error.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Controller?.Dispose(); + Controller = null; + } +} diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 82400411..36e628b8 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -1716,7 +1716,10 @@ public static class ItemAppraisalTextFormatter }; /// AppraisalSystem::SkillToString @ 0x005B4A30. - private static string SkillName(int skill) => skill switch + /// 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", 2 => "Bow", diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 91c559c9..4adbb823 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -427,7 +427,9 @@ public sealed record RetailUiRuntimeBindings( RetailUiPersistenceBindings? Persistence, RetailUiProbeBindings Probe, KeyboardRuntimeBindings? Keyboard = null, - CharacterSelectionRuntimeBindings? CharacterSelection = null); + CharacterSelectionRuntimeBindings? CharacterSelection = null, + // Campaign CC slice CC4: sibling of CharacterSelection above. + CharacterCreationRuntimeBindings? CharacterCreation = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -450,6 +452,7 @@ public sealed class RetailUiRuntime : IDisposable private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; private CharacterManagementUiMountCoordinator? _characterManagementMount; + private CharacterCreationUiMountCoordinator? _characterCreationMount; private IDisposable? _characterSheetSubscription; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -518,6 +521,8 @@ public sealed class RetailUiRuntime : IDisposable MountItemCooldowns(); ConfigureCharacterManagement(); _characterManagementMount?.Tick(); + ConfigureCharacterCreation(); + _characterCreationMount?.Tick(); Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; BindToolbarPanelButtons(); SyncToolbarWindowButtons(); @@ -614,6 +619,8 @@ public sealed class RetailUiRuntime : IDisposable public SocialPanelController? SocialPanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; + internal CharacterCreationUiController? CharacterCreationController => + _characterCreationMount?.Controller; public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { @@ -661,6 +668,8 @@ public sealed class RetailUiRuntime : IDisposable _itemCooldownController?.Tick(); _characterManagementMount?.Tick(); CharacterManagementController?.Tick(); + _characterCreationMount?.Tick(); + CharacterCreationController?.Tick(); DialogFactory?.Tick(); Host.Tick(deltaSeconds); _automation?.Tick(deltaSeconds); @@ -3875,6 +3884,87 @@ public sealed class RetailUiRuntime : IDisposable private static string NormalizeRetailNewlines(string value) => value.Replace("\\n", "\n", StringComparison.Ordinal); + private void ConfigureCharacterCreation() + { + CharacterCreationRuntimeBindings? bindings = _bindings.CharacterCreation; + if (bindings is null || _characterCreationMount is not null) + return; + + _characterCreationMount = new CharacterCreationUiMountCoordinator( + Host.Root, + bindings, + EnsureDialogFactory, + LoadCharacterCreationResources); + } + + private CharacterCreationUiMountResources? LoadCharacterCreationResources() + { + const uint stringTableId = 0x23000002u; + uint layoutId; + ImportedLayout? layout; + var strings = new DatStringResolver(_bindings.Assets.Dats); + lock (_bindings.Assets.DatLock) + { + // gmCharGenMainUI's framework registration passes enum + // 0x10000039 and category/table 5, then selects root 0x100003CC. + layoutId = RetailDataIdResolver.Resolve( + _bindings.Assets.Dats, + CharacterCreationUiController.RootEnum, + 5u); + layout = layoutId == 0u + ? null + : LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + CharacterCreationUiController.RootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + + if (layout is null) + { + Console.WriteLine( + "[UI] character creation: enum-table-5 root could not be imported."); + return null; + } + + string? exitWarning; + lock (_bindings.Assets.DatLock) + { + exitWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_ExitWarning"); + } + if (exitWarning is null) + { + Console.WriteLine( + "[UI] character creation: required retail strings are unavailable."); + return null; + } + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + { + return LayoutImporter.Import( + _bindings.Assets.Dats, + templateLayoutId, + templateElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont)?.Root; + } + } + + return new CharacterCreationUiMountResources( + layoutId, + layout, + ResolveTemplate, + new CharacterCreationUiController.DialogStrings(exitWarning)); + } + private void MountItemCooldowns() { ItemCooldownAssets? assets; @@ -3921,6 +4011,7 @@ public sealed class RetailUiRuntime : IDisposable () => { _characterManagementMount?.Dispose(); + _characterCreationMount?.Dispose(); _gameplayConfirmationController?.Dispose(); }, () => DialogFactory?.Dispose(), diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 21b168f3..8f0735bd 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -409,7 +409,18 @@ internal sealed class HeadlessSessionHost : IDisposable descriptor.Id, selection.CharacterId, selection.CharacterName), - loginCommands)); + loginCommands, + // Campaign CC slice CC4: same status-parity wiring as + // the graphical host (LiveSessionRuntimeFactory.Create). + CharacterCreated: identity => statusWriter.CharacterCreated( + descriptor.Id, + identity.Guid, + identity.Name), + CreationFailed: rejection => statusWriter.CreationFailed( + descriptor.Id, + rejection.RawCode, + rejection.Reason, + rejection.AttemptedName))); Runtime = runtime; Commands = commands; diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index fadf5bd9..3815b305 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -533,6 +533,8 @@ public sealed class GameRuntime public IRuntimeChatView Chat => CommunicationOwner.View; public IRuntimeCharacterSelectionView CharacterSelection => Session.CharacterSelection; + public IRuntimeCharacterCreationView CharacterCreation => + Session.CharacterCreation; public IRuntimeFellowshipView Fellowship => FellowshipOwner.View; public IRuntimeAllegianceView Allegiance => AllegianceOwner.View; diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index ca973e34..3c9baf30 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -280,6 +280,12 @@ public interface IGameRuntimeView throw new NotSupportedException( "This runtime view does not project character selection."); + /// Campaign CC slice CC4: mirrors 's + /// default-throw shape. + IRuntimeCharacterCreationView CharacterCreation => + throw new NotSupportedException( + "This runtime view does not project character creation."); + IRuntimeFellowshipView Fellowship { get; } IRuntimeAllegianceView Allegiance { get; } diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 1782f9d4..9d977419 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -41,7 +41,16 @@ public sealed record LiveSessionHostBindings( /// fan-out, this exists so a status writer can emit the /// enteredWorld event's characterId field. Action CharacterEntered, - LoginCommandSequence? LoginCommands = null); + LoginCommandSequence? LoginCommands = null, + /// Campaign CC slice CC4: forwards + /// — + /// hosts wire this to SessionStatusWriter.CharacterCreated. + /// Default no-op. + Action? CharacterCreated = null, + /// Campaign CC slice CC4: forwards + /// — hosts + /// wire this to SessionStatusWriter.CreationFailed. + Action? CreationFailed = null); /// /// Runtime host for the one canonical . @@ -136,7 +145,9 @@ public sealed class LiveSessionHost Connected: bindings.Connected, Roster: bindings.Roster, Selected: ApplySelection, - Entered: ApplyEnteredWorld)); + Entered: ApplyEnteredWorld, + CharacterCreated: bindings.CharacterCreated, + CreationFailed: bindings.CreationFailed)); } public WorldSession? CurrentSession => _controller.CurrentSession; diff --git a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs index 7f1a7f92..c78db435 100644 --- a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs @@ -9,7 +9,15 @@ public sealed record LiveSessionLifecycleBindings( Action Connected, Action Roster, Action Selected, - Action Entered); + Action Entered, + /// Campaign CC slice CC4: forwards + /// . Default + /// no-op preserves every existing positional/named construction site + /// that predates this field. + Action? CharacterCreated = null, + /// Campaign CC slice CC4: forwards + /// . + Action? CreationFailed = null); /// /// Focused adapter between the session lifetime owner and its composition @@ -62,6 +70,12 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => _bindings.Entered(selection); + public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => + _bindings.CharacterCreated?.Invoke(identity); + + public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => + _bindings.CreationFailed?.Invoke(rejection); + public void DetachSession(WorldSession session) { if (!ReferenceEquals(_boundSession, session)) diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index f38ee9ee..85696ced 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -281,7 +281,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable private readonly object _gate = new(); private readonly CharacterCreationEventStream _events = new(); private readonly ViewProjection _view; - private readonly ChargenOptions _options; + private ChargenOptions _options; private readonly Random _random; private RuntimeGenerationToken _generation; private bool _active; @@ -321,6 +321,39 @@ public sealed class RuntimeCharacterCreationState : IDisposable public ChargenOptions Options => _options; + /// + /// Campaign CC slice CC4: installs the real chargen options loaded from + /// the installed DAT (AcDream.Content.CharGen.ChargenTableReader.Load) + /// once the content host's DAT collection opens, mirroring the + /// established "install immutable DAT metadata after construction" + /// pattern (RuntimeCharacterState.InstallSpellMetadata -> + /// Spellbook.InstallMetadata). GameWindow constructs the + /// (and therefore this state, + /// defaulted to ) before portal.dat is + /// open; ContentEffectsAudioCompositionPhase.Compose calls this + /// once DATs are published, always well before + /// — no character-selection/creation session can be + /// active yet at that point in the composition sequence, so there is no + /// concurrent read to race. Throws if called while a session is already + /// active — a second install after chargen has started reading the + /// first one would be a genuine caller bug, not a case to silently + /// tolerate. + /// + public void InstallOptions(ChargenOptions options) + { + ArgumentNullException.ThrowIfNull(options); + lock (_gate) + { + ThrowIfDisposed(); + if (_active) + { + throw new InvalidOperationException( + "Chargen options cannot be installed while a character-creation session is active."); + } + _options = options; + } + } + public RuntimeCharacterCreationSnapshot Snapshot { get diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index 7b6de332..730c54ad 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -10,6 +10,7 @@ using AcDream.App.Spells; using AcDream.Content; using AcDream.Content.Vfx; using AcDream.Core.Audio; +using AcDream.Core.CharGen; using AcDream.Core.Lighting; using AcDream.Core.Physics; using AcDream.Core.Rendering; @@ -17,6 +18,7 @@ using AcDream.Core.Spells; using AcDream.Core.Vfx; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using DatReaderWriter.DBObjs; using Silk.NET.Input; using Silk.NET.OpenAL; @@ -403,6 +405,11 @@ public sealed class ContentEffectsAudioCompositionTests RuntimeCharacterState character, MagicCatalog catalog) { } public int GetSpellCount(MagicCatalog catalog) => 0; + public ChargenOptions LoadChargenOptions(IDatReaderWriter dats) => + ChargenOptions.Empty; + public void InstallChargenOptions( + LiveSessionController session, + ChargenOptions options) { } public IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs new file mode 100644 index 00000000..cd1135a3 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -0,0 +1,334 @@ +using System.IO; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Installed-retail-DAT acceptance gate for Campaign CC slice CC4. Opt in +/// with ACDREAM_PROBE_LIVE_MOUNT=1; ACDREAM_DAT_DIR can +/// override the ordinary Documents/Asheron's Call location. Mirrors +/// 's pattern: sweeps the +/// authored master-shell and page ids the campaign plan and CC4's own +/// decomp research cite, pinning them against the real installed layout. +/// +public sealed class CharacterCreationLiveDatTests +{ + private static string DatDirectory => + Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + + [InstalledDatFact] + public void EnumTable5_ResolvesTheMasterShellRootAndChildren() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + Assert.NotEqual(0u, layoutId); + Console.WriteLine( + $"[CC4-DAT] category=5 enum=0x10000039 -> DID=0x{layoutId:X8}"); + + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + Assert.Equal( + CharacterCreationUiController.RootElementId, + screen.Root.DatElementId); + + Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProgressBarElementId)); + AssertButton(screen, CharacterCreationUiController.BackElementId); + AssertButton(screen, CharacterCreationUiController.NextElementId); + AssertButton(screen, CharacterCreationUiController.FinishElementId); + AssertButton(screen, CharacterCreationUiController.HelpElementId); + AssertButton(screen, CharacterCreationUiController.ExitElementId); + AssertButton(screen, CharacterCreationUiController.RandomElementId); + Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.MasterPageElementId)); + foreach (uint pageId in new[] + { + CharacterCreationUiController.HeritagePageElementId, + CharacterCreationUiController.ProfessionPageElementId, + CharacterCreationUiController.SkillsPageElementId, + CharacterCreationUiController.AppearancePageElementId, + CharacterCreationUiController.TownPageElementId, + CharacterCreationUiController.SummaryPageElementId, + }) + { + Assert.IsAssignableFrom(screen.FindElement(pageId)); + } + AssertButton(screen, CharacterCreationUiController.HeritageTabElementId); + AssertButton(screen, CharacterCreationUiController.ProfessionTabElementId); + AssertButton(screen, CharacterCreationUiController.SkillsTabElementId); + AssertButton(screen, CharacterCreationUiController.AppearanceTabElementId); + AssertButton(screen, CharacterCreationUiController.TownTabElementId); + AssertButton(screen, CharacterCreationUiController.SummaryTabElementId); + } + + [InstalledDatFact] + public void MountsThroughTheControllerAgainstLiveResources() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + var host = new UiRoot(); + var dialogs = MakeDialogFactory(dats, host); + var bindings = new CharacterCreationRuntimeBindings( + () => null, + _ => default, + _ => default, + _ => default, + (_, _) => default, + (_, _) => default, + _ => default, + _ => default, + _ => default, + _ => default, + _ => default, + () => { }); + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) => + LayoutImporter.Import( + dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root; + + CharacterCreationUiController? controller = + CharacterCreationUiController.CreateDetached( + host, screen, ResolveTemplate, dialogs, bindings, + new CharacterCreationUiController.DialogStrings("Are you sure?")); + Assert.NotNull(controller); + controller!.AttachAndTick(); + controller.Dispose(); + dialogs.Dispose(); + } + + /// 13 heritage buttons, the description text, all present as + /// authored (Heritage page — gmCGHeritagePage::InitializePage @ + /// 0x00483a10). + [InstalledDatFact] + public void HeritagePage_HasAllThirteenRaceButtonsAndDescriptionText() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement heritageRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + + uint[] heritageButtonIds = + [ + 0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u, + 0x10000590u, 0x100005A9u, 0x100005E8u, 0x100005F1u, + 0x100005C4u, 0x10000591u, 0x100005BFu, 0x100005C7u, + 0x100005C8u, + ]; + foreach (uint buttonId in heritageButtonIds) + { + Assert.IsType( + UiElement.FindDescendant(heritageRoot, buttonId)); + } + Assert.IsType( + UiElement.FindDescendant(heritageRoot, 0x100003C4u)); + } + + /// Seven template buttons, six attribute sliders (each with a + /// lock button + scrollbar + value text), and the four derived + /// displays (Profession page — + /// gmCGProfessionPage::InitializePage @ 0x00482d50). + [InstalledDatFact] + public void ProfessionPage_HasTemplateButtonsSlidersAndDisplays() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement professionRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProfessionPageElementId)); + + uint[] templateButtonIds = + [ + 0x100003D9u, 0x100003DAu, 0x100003DBu, + 0x100003DCu, 0x100003DDu, 0x100003DEu, 0x100003DFu, + ]; + foreach (uint buttonId in templateButtonIds) + { + Assert.IsType( + UiElement.FindDescendant(professionRoot, buttonId)); + } + + uint[] sliderContainerIds = + [ + 0x100003E6u, 0x100003E7u, 0x100003E8u, + 0x100003E9u, 0x100003EAu, 0x100003EBu, + ]; + foreach (uint containerId in sliderContainerIds) + { + UiElement container = Assert.IsAssignableFrom( + UiElement.FindDescendant(professionRoot, containerId)); + Assert.IsType( + UiElement.FindDescendant(container, 0x100002EEu)); + // The value display authors as an editable Type-12 (retail's + // NumberInputFilter) — DatWidgetFactory maps that to UiField, + // not UiText. See CharacterCreationProfessionPage's ctor comment. + Assert.IsType( + UiElement.FindDescendant(container, 0x100002EFu)); + } + + // Avail/health/stamina/mana each author as a Button whose Type-12 + // value child is swallowed by UiButton.ConsumesDatChildren — the + // same substitution the Skills page's credits meter needed. See + // CharacterCreationProfessionPage's ctor comment. + foreach (uint containerId in new[] + { 0x100003E2u, 0x100003E3u, 0x100003E4u, 0x100003E5u }) + { + Assert.IsType( + UiElement.FindDescendant(professionRoot, containerId)); + } + } + + /// Skills listbox, credits meter, info panes (Skills page — + /// gmCGSkillsPage::InitializePage @ 0x00481dd0). + [InstalledDatFact] + public void SkillsPage_HasListboxCreditsAndInfoPanes() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement skillsRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SkillsPageElementId)); + + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003F7u)); + // The credits meter (decomp id 0x100002f3) authors as a raw dat + // child of button 0x100003f9; UiButton.ConsumesDatChildren swallows + // it before it becomes an addressable widget, so the faithful + // acdream substitute is the button's own Label — see + // CharacterCreationSkillsPage's ctor comment. + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003F9u)); + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003FBu)); + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003FCu)); + } + + /// Four town buttons + description text (Town page — + /// gmCGTownPage::InitializePage @ 0x0047c6d0). + [InstalledDatFact] + public void TownPage_HasFourStarterAreaButtons() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement townRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.TownPageElementId)); + + foreach (uint buttonId in new[] { 0x1000040Bu, 0x1000040Du, 0x1000040Eu, 0x1000040Fu }) + { + Assert.IsType( + UiElement.FindDescendant(townRoot, buttonId)); + } + Assert.IsType( + UiElement.FindDescendant(townRoot, 0x10000409u)); + } + + /// The exit-warning + all per-heritage/per-town DAT string + /// keys this slice cites actually resolve in the installed table. + /// + [InstalledDatFact] + public void RequiredChargenStringsResolve() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + var strings = new DatStringResolver(dats); + const uint table = 0x23000002u; + + string[] keys = + [ + "ID_CharGen_ExitWarning", + "ID_CharGen_Heritage_StartingSkills_Header", + "ID_CharGen_Heritage_StartingSkills", + "ID_CharGen_Heritage_BonusSkills_Trained_Header", + "ID_CharGen_AluvianText_BonusSkills_Trained", + "ID_CharGen_GaruText_BonusSkills_Trained", + "ID_CharGen_ShoText_BonusSkills_Trained", + "ID_CharGen_ViaText_BonusSkills_Trained", + "ID_CharGen_ShadText_BonusSkills_Trained", + "ID_CharGen_GearText_BonusSkills_Trained", + "ID_CharGen_AunTText_BonusSkills_Trained", + "ID_CharGen_EmpText_BonusSkills_Trained", + "ID_CharGen_UndText_BonusSkills_Trained", + "ID_CharGen_TownHowTo", + "ID_CharGen_HoltText", + "ID_CharGen_ShoushiText", + "ID_CharGen_YaraqText", + "ID_CharGen_SanamarText", + ]; + foreach (string key in keys) + { + string? resolved = strings.Resolve(table, DatStringResolver.ComputeHash(key)); + Assert.True(resolved is not null, $"missing string: {key}"); + } + } + + private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host) + { + uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); + ImportedLayout? CreateLayout(RetailDialogType type) + { + uint rootElementId = RetailDialogFactory.RootElementId(type); + return rootElementId == 0u + ? null + : LayoutImporter.Import( + dats, dialogDid, rootElementId, _ => (0u, 0, 0), null); + } + return new RetailDialogFactory(host, CreateLayout); + } + + private static void AssertButton(ImportedLayout layout, uint elementId) => + Assert.IsType(layout.FindElement(elementId)); + + private static ImportedLayout BuildSelected( + IDatReaderWriter dats, + uint layoutDid, + uint rootId) + { + ElementInfo info = Assert.IsType( + LayoutImporter.ImportInfos(dats, layoutDid, rootId)); + return LayoutImporter.Build( + info, + _ => (0u, 0, 0), + null, + null, + new DatStringResolver(dats).Resolve); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs new file mode 100644 index 00000000..e8e8216d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -0,0 +1,817 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC slice CC4 — controller binding tests for the character- +/// creation master shell + Heritage/Profession/Skills/Town pages, using a +/// hand-built layout fixture (no installed DAT — see +/// for the live-DAT id/type +/// sweep this pairs with). Mirrors CharacterManagementUiControllerTests' +/// fixture pattern. +/// +public sealed class CharacterCreationUiControllerTests +{ + private const uint AluvianId = 1u; + private const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi; + private const uint GenderKey = 1u; + private const uint SkillTrainOnly = 1u; + private const uint SkillSpecializable = 2u; + + [Fact] + public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage() + { + using var environment = new EnvironmentHarness(); + Assert.False(environment.Controller.Root.Visible); + + environment.Controller.Open(); + + Assert.True(environment.Controller.Root.Visible); + Assert.Equal(800f, environment.Controller.Root.Width); + Assert.Equal(600f, environment.Controller.Root.Height); + Assert.True(environment.Page( + CharacterCreationUiController.HeritagePageElementId).Visible); + Assert.False(environment.Page( + CharacterCreationUiController.ProfessionPageElementId).Visible); + Assert.True(environment.TabButton( + CharacterCreationUiController.HeritageTabElementId).Selected); + } + + [Fact] + public void TabClick_SwitchesToTheClickedPage_FreeOfValidation() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + // Free navigation: jumping straight to Town from Heritage with + // nothing selected must still work (gmCharGenMainUI's tab dispatch + // is not gated — see ApplyProgressState's doc). + environment.TabButton(CharacterCreationUiController.TownTabElementId) + .OnClick!(); + + Assert.True(environment.Page( + CharacterCreationUiController.TownPageElementId).Visible); + Assert.False(environment.Page( + CharacterCreationUiController.HeritagePageElementId).Visible); + Assert.True(environment.TabButton( + CharacterCreationUiController.TownTabElementId).Selected); + } + + [Fact] + public void Next_AdvancesOnePageAtATime_AndBackReturns() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.NextElementId).OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.ProfessionPageElementId).Visible); + + environment.Button(CharacterCreationUiController.BackElementId).OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.HeritagePageElementId).Visible); + } + + [Fact] + public void Next_AtSummary_IsANoOp() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.SummaryTabElementId) + .OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.SummaryPageElementId).Visible); + + environment.Button(CharacterCreationUiController.NextElementId).OnClick!(); + + Assert.True(environment.Page( + CharacterCreationUiController.SummaryPageElementId).Visible); + } + + [Fact] + public void Finish_StaysGhosted_NoOnClickHandler() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId); + Assert.Null(finish.OnClick); + Assert.False(finish.Enabled); + } + + [Fact] + public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + UiButton random = environment.Button(CharacterCreationUiController.RandomElementId); + Assert.True(random.Enabled); + + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + Assert.False(random.Enabled); + + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + Assert.False(random.Enabled); + + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + Assert.False(random.Enabled); + + environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!(); + Assert.True(random.Enabled); + } + + /// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's + /// element 0x100003c6 case: at Heritage (the first page), Back opens + /// the exit confirmation instead of moving pages. + [Fact] + public void Back_AtHeritage_OpensExitConfirmation() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.BackElementId).OnClick!(); + + Assert.True(environment.Dialogs.IsOpen); + } + + [Fact] + public void Exit_Confirm_ClosesTheScreenAndCallsRequestExit() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.ExitElementId).OnClick!(); + Assert.True(environment.Dialogs.IsOpen); + + environment.ConfirmActiveDialog(confirmed: true); + + Assert.False(environment.Controller.Root.Visible); + Assert.Equal(1, environment.Runtime.RequestExitCalls); + } + + [Fact] + public void Exit_Cancel_LeavesTheScreenOpen() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.ExitElementId).OnClick!(); + environment.ConfirmActiveDialog(confirmed: false); + + Assert.True(environment.Controller.Root.Visible); + Assert.Equal(0, environment.Runtime.RequestExitCalls); + } + + /// gmCGHeritagePage::ListenToElementMessage @ 0x00483860's + /// per-button SetHeritageGroup literal, plus CC4's interim + /// auto-gender-select seam (register AD-100). + [Fact] + public void HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(0x100003BFu).OnClick!(); // Aluvian + + Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage); + Assert.Equal(GenderKey, environment.Runtime.LastSelectedGender); + } + + /// gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi + /// branch: Profession/Skills/Town tabs hide, and paging past the + /// hidden range redirects to Appearance/Summary. + [Fact] + public void OlthoiHeritage_HidesProfessionSkillsAndTownTabs() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(OlthoiId); + + environment.TabButton(CharacterCreationUiController.HeritageTabElementId) + .OnClick!(); + + Assert.False(environment.TabButton( + CharacterCreationUiController.ProfessionTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.SkillsTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.TownTabElementId).Visible); + + // Next from Heritage would normally land on Profession; for an + // Olthoi heritage it must redirect straight to Appearance. + environment.Button(CharacterCreationUiController.NextElementId).OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.AppearancePageElementId).Visible); + } + + [Fact] + public void ProfessionTemplateButton_SelectsTemplate() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId) + .OnClick!(); + + environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template index 1 + + Assert.Equal(1u, environment.Runtime.LastSelectedTemplate); + } + + [Fact] + public void ProfessionSlider_ScalarChange_SetsTheAttribute() + { + 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!(1f); // top of the [10,100] range + + Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet); + Assert.Equal(100, environment.Runtime.LastAttributeValue); + } + + [Fact] + public void ProfessionValueField_DirectEntry_SetsTheAttribute() + { + 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 field = Assert.IsType( + UiElement.FindDescendant(strengthContainer, 0x100002EFu)); + + field.OnSubmit!("42"); + + Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet); + Assert.Equal(42, environment.Runtime.LastAttributeValue); + } + + [Fact] + public void SkillsRow_Click_TrainsThenSpecializes() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId) + .OnClick!(); + + // Rows are built in ascending skill-id order (RebuildRows' 1..54 + // walk over IsCostable ids) — SkillSpecializable's row is identified + // by its label prefix (FormatSkillLabel's "{name}: ..." shape) + // rather than instance identity, since the controller owns the + // row->skillId map privately. + string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable); + UiButton row = environment.SkillsList().ViewportForTest!.Children + .OfType() + .Single(candidate => candidate.Label!.StartsWith( + skillName + ":", StringComparison.Ordinal)); + + row.OnClick!(); + Assert.Equal(ChargenSkillAdvancementClass.Trained, + environment.Runtime.GetSkillLevel(SkillSpecializable)); + + row.OnClick!(); + Assert.Equal(ChargenSkillAdvancementClass.Specialized, + environment.Runtime.GetSkillLevel(SkillSpecializable)); + + row.OnDoubleClick!(); + Assert.Equal(ChargenSkillAdvancementClass.Trained, + environment.Runtime.GetSkillLevel(SkillSpecializable)); + } + + [Fact] + public void TownButton_SelectsTheLiteralStartAreaIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.TownTabElementId) + .OnClick!(); + + // Holtburg (0x1000040d) -> startArea 0 per SetTown's literal map. + environment.Button(0x1000040Du).OnClick!(); + Assert.Equal(0, environment.Runtime.LastSelectedStartArea); + + // Yaraq (0x1000040e) -> startArea 2. + environment.Button(0x1000040Eu).OnClick!(); + Assert.Equal(2, environment.Runtime.LastSelectedStartArea); + } + + private static IEnumerable Descendants(UiElement root) + { + yield return root; + foreach (UiElement child in root.Children) + foreach (UiElement descendant in Descendants(child)) + yield return descendant; + } + + // ── Fixture ────────────────────────────────────────────────────────── + + private sealed class EnvironmentHarness : IDisposable + { + private readonly List _dialogLayouts = []; + + public EnvironmentHarness() + { + Host = new UiRoot { Width = 800f, Height = 600f }; + Screen = BuildScreen(); + Runtime = new FakeRuntime(); + Dialogs = new RetailDialogFactory(Host, type => + { + ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type); + _dialogLayouts.Add(layout); + return layout; + }); + Controller = Assert.IsType( + CharacterCreationUiController.CreateDetached( + Host, + Screen, + ResolveSkillRowTemplate, + Dialogs, + Runtime.Bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure you want to leave?"))); + Controller.AttachAndTick(); + } + + public UiRoot Host { get; } + public ImportedLayout Screen { get; } + public FakeRuntime Runtime { get; } + public RetailDialogFactory Dialogs { get; } + public CharacterCreationUiController Controller { get; } + + public UiButton Button(uint id) => + Assert.IsType(Screen.FindElement(id)); + + public UiButton TabButton(uint id) => Button(id); + + public UiElement Page(uint id) => + Assert.IsAssignableFrom(Screen.FindElement(id)); + + public UiTemplateListBox SkillsList() => + Assert.IsType(Screen.FindElement(0x100003F7u)); + + /// Confirms or cancels the MOST RECENTLY opened confirmation + /// dialog, using 's real + /// button ids off the layout the factory's createLayout + /// callback actually returned — same lookup shape + /// CharacterManagementUiControllerTests uses. + public void ConfirmActiveDialog(bool confirmed) + { + ImportedLayout dialog = _dialogLayouts[^1]; + uint buttonId = confirmed + ? RetailConfirmationDialogView.AcceptButtonId + : RetailConfirmationDialogView.RejectButtonId; + UiButton button = Assert.IsType(dialog.FindElement(buttonId)); + button.OnClick!(); + } + + private static UiElement? ResolveSkillRowTemplate( + uint templateLayoutId, + uint templateElementId) => + BuildSkillRowTemplate(templateElementId); + + public void Dispose() + { + Controller.Dispose(); + Dialogs.Dispose(); + } + } + + private sealed class FakeRuntime + { + private static readonly RuntimeGenerationToken Generation = new(3u); + + public FakeRuntime() + { + View = new FakeView(BuildOptions()); + Bindings = new CharacterCreationRuntimeBindings( + () => ProvideView ? View : null, + SelectHeritage, + SelectGender, + SelectTemplate, + SetAttribute, + (_, _) => Result(RuntimeCommandStatus.Accepted), + skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Trained), + skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized), + skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained), + SelectStartArea, + _ => Result(RuntimeCommandStatus.Accepted), + () => RequestExitCalls++, + ResolveText: _ => null, + OpenOnStart: false); + } + + public FakeView View { get; } + public CharacterCreationRuntimeBindings Bindings { get; } + public bool ProvideView { get; set; } = true; + public int RequestExitCalls { get; private set; } + public uint LastSelectedHeritage { get; private set; } + public uint LastSelectedGender { get; private set; } + public uint LastSelectedTemplate { get; private set; } + public ChargenAttributeId LastAttributeSet { get; private set; } + public int LastAttributeValue { get; private set; } + public int LastSelectedStartArea { get; private set; } = -1; + + public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + View.GetSkillLevel(skillId); + + private RuntimeCommandResult SelectHeritage(uint heritageId) + { + LastSelectedHeritage = heritageId; + View.Snapshot = View.Snapshot with { HeritageId = heritageId }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SelectGender(uint genderKey) + { + LastSelectedGender = genderKey; + View.Snapshot = View.Snapshot with { GenderKey = genderKey }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SelectTemplate(uint templateIndex) + { + LastSelectedTemplate = templateIndex; + View.Snapshot = View.Snapshot with { Template = templateIndex }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SetAttribute(ChargenAttributeId attribute, int value) + { + LastAttributeSet = attribute; + LastAttributeValue = value; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SetSkillLevel( + uint skillId, + ChargenSkillAdvancementClass targetClass) + { + View.SetSkillLevel(skillId, targetClass); + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SelectStartArea(int startAreaIndex) + { + LastSelectedStartArea = startAreaIndex; + View.Snapshot = View.Snapshot with { StartArea = startAreaIndex }; + return Result(RuntimeCommandStatus.Accepted); + } + + private static RuntimeCommandResult Result(RuntimeCommandStatus status) => + new(status, Generation); + + private static ChargenOptions BuildOptions() + { + var gender = new ChargenGenderOptions( + GenderKey: (int)GenderKey, + Name: "Male", + Scale: 1u, + SetupId: 0x2000054u, + SoundTableId: 0u, + IconId: 0u, + BasePaletteId: 0u, + SkinPalSetId: 0u, + PhysicsTableId: 0u, + MotionTableId: 0u, + CombatTableId: 0u, + BaseObjDesc: ChargenObjDesc.Empty, + HairColors: [], + HairStyles: [], + EyeColors: [], + EyeStrips: [], + NoseStrips: [], + MouthStrips: [], + Headgears: [], + Shirts: [], + Pants: [], + Footwear: [], + ClothingColors: []); + + var templates = new List + { + new( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + new( + "Bow Hunter", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10), + NormalSkills: [SkillTrainOnly], + PrimarySkills: []), + }; + + var skillCosts = new Dictionary + { + [SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6), + [SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6), + }; + + var aluvian = new ChargenHeritageOptions( + AluvianId, + "Aluvian", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 66u, + SkillCredits: 50u, + PrimaryStartAreaIndices: [0, 1], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: skillCosts, + Templates: templates, + GendersByKey: new Dictionary { [(int)GenderKey] = gender }); + + var olthoi = new ChargenHeritageOptions( + OlthoiId, + "Olthoi", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 60u, + SkillCredits: 0u, + PrimaryStartAreaIndices: [0], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: new Dictionary(), + Templates: + [ + new ChargenTemplate( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + ], + GendersByKey: new Dictionary { [(int)GenderKey] = gender }); + + var starterAreas = new List + { + new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]), + new(1, "Shoushi", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]), + new(2, "Yaraq", [new ChargenPosition(3u, Vector3.Zero, Quaternion.Identity)]), + new(3, "Sanamar", [new ChargenPosition(4u, Vector3.Zero, Quaternion.Identity)]), + }; + + return new ChargenOptions( + starterAreas, + new Dictionary + { + [AluvianId] = aluvian, + [OlthoiId] = olthoi, + }, + new Dictionary()); + } + } + + private sealed class FakeView(ChargenOptions options) : IRuntimeCharacterCreationView + { + private readonly Dictionary _skillLevels = []; + + public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = + new( + new RuntimeGenerationToken(3u), + IsActive: true, + Revision: 1, + HeritageId: 0u, + GenderKey: 0u, + Appearance: RuntimeCharacterCreationAppearance.Default, + Template: RuntimeCharacterCreationSnapshot.TemplateUnset, + Attributes: default, + AttributeLockMask: 0u, + TotalAttributeCredits: 66u, + RemainingAttributeCredits: 66, + TotalSkillCredits: 50u, + RemainingSkillCredits: 50, + Name: string.Empty, + StartArea: -1, + Slot: 0u, + VerificationPending: false, + LastLocalRefusal: default, + LastRejection: null, + LastCreated: null); + + public ChargenOptions Options { get; } = options; + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + _skillLevels.TryGetValue(skillId, out ChargenSkillAdvancementClass level) + ? level + : ChargenSkillAdvancementClass.Inactive; + + public void SetSkillLevel(uint skillId, ChargenSkillAdvancementClass level) => + _skillLevels[skillId] = level; + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => + NullSubscription.Instance; + + private sealed class NullSubscription : IDisposable + { + public static readonly NullSubscription Instance = new(); + public void Dispose() { } + } + } + + private static ImportedLayout BuildScreen() + { + var root = new ElementInfo + { + Id = CharacterCreationUiController.RootElementId, + Type = 3u, + Width = 800f, + Height = 600f, + }; + + root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId)); + + root.Children.Add(BuildHeritagePage()); + root.Children.Add(BuildProfessionPage()); + root.Children.Add(BuildSkillsPage()); + root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId)); + root.Children.Add(BuildTownPage()); + root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); + + root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId)); + + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + + private static ElementInfo BuildHeritagePage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.HeritagePageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + page.Children.Add(ButtonInfo(0x100003BFu)); // Aluvian + page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi + page.Children.Add(TextInfo(0x100003C4u)); + return page; + } + + private static ElementInfo BuildProfessionPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.ProfessionPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + page.Children.Add(ButtonInfo(0x100003D9u)); // Custom + page.Children.Add(ButtonInfo(0x100003DAu)); // Bow Hunter + + var strengthSlider = ContainerInfo(0x100003E6u); + strengthSlider.Children.Add(ButtonInfo(0x100002ECu)); + strengthSlider.Children.Add(ScrollbarInfo(0x100002EEu)); + strengthSlider.Children.Add(EditableFieldInfo(0x100002EFu)); + page.Children.Add(strengthSlider); + + page.Children.Add(ButtonInfo(0x100003E2u)); // Available (consumed-child badge) + page.Children.Add(ButtonInfo(0x100003E3u)); // Health + page.Children.Add(ButtonInfo(0x100003E4u)); // Stamina + page.Children.Add(ButtonInfo(0x100003E5u)); // Mana + return page; + } + + private static ElementInfo BuildSkillsPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.SkillsPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + var list = new ElementInfo + { + Id = 0x100003F7u, + Type = 5u, + X = 20f, + Y = 40f, + Width = 300f, + Height = 320f, + }; + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu)); + page.Children.Add(list); + page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge + page.Children.Add(TextInfo(0x100003FBu)); + page.Children.Add(TextInfo(0x100003FCu)); + return page; + } + + private static ElementInfo BuildTownPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.TownPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + page.Children.Add(ButtonInfo(0x1000040Bu)); // Sanamar + page.Children.Add(ButtonInfo(0x1000040Du)); // Holtburg + page.Children.Add(ButtonInfo(0x1000040Eu)); // Yaraq + page.Children.Add(ButtonInfo(0x1000040Fu)); // Shoushi + page.Children.Add(TextInfo(0x10000409u)); + return page; + } + + private static UiElement BuildSkillRowTemplate(uint templateElementId) => + LayoutImporter.Build( + new ElementInfo + { + Id = templateElementId, + Type = 1u, + Width = 280f, + Height = 16f, + }, + _ => (0u, 0, 0), + null).Root; + + private static ElementInfo ContainerInfo(uint id) => new() + { + Id = id, + Type = 3u, + Width = 200f, + Height = 60f, + }; + + private static ElementInfo ButtonInfo(uint id) => new() + { + Id = id, + Type = 1u, + Width = 100f, + Height = 30f, + }; + + private static ElementInfo TextInfo(uint id) => new() + { + Id = id, + Type = 12u, + Width = 200f, + Height = 60f, + }; + + private static ElementInfo ScrollbarInfo(uint id) => new() + { + Id = id, + Type = 11u, + Width = 120f, + Height = 12f, + }; + + private static ElementInfo EditableFieldInfo(uint id) + { + var info = new ElementInfo + { + Id = id, + Type = 12u, + Width = 40f, + Height = 16f, + }; + var state = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + state.Properties.Values[0x16u] = new UiPropertyValue + { + Kind = UiPropertyKind.Bool, + BoolValue = true, + }; + info.States[UiStateInfo.DirectStateId] = state; + return info; + } +} diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs index d3f93985..175364ea 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -57,6 +57,54 @@ public sealed class RuntimeCharacterCreationStateTests Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); } + // ── Options threading (Campaign CC slice CC4) ─────────────────────── + // RuntimeCharacterCreationState.InstallOptions — the App-startup seam + // ContentEffectsAudioCompositionPhase.Compose calls once portal.dat's + // ChargenTableReader.Load result is available, mirroring + // RuntimeCharacterState.InstallSpellMetadata's "install immutable DAT + // metadata after construction" pattern. + + [Fact] + public void InstallOptions_BeforeBegin_ReplacesTheOptionsLaterCommandsUse() + { + var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); + + state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + Assert.Equal( + RuntimeCharacterCreationStateFixture.AluvianId, + state.Snapshot.HeritageId); + } + + [Fact] + public void InstallOptions_WhileActive_ThrowsInsteadOfRacingLiveCommands() + { + RuntimeCharacterCreationState state = CreateActive(); + + Assert.Throws( + () => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build())); + } + + [Fact] + public void InstallOptions_NullOptions_Throws() + { + var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); + + Assert.Throws(() => state.InstallOptions(null!)); + } + + [Fact] + public void InstallOptions_AfterDispose_Throws() + { + var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); + state.Dispose(); + + Assert.Throws( + () => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build())); + } + // ── Heritage / gender / template ──────────────────────────────────── [Fact] diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs index 48064bc3..7acf96e5 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs @@ -40,6 +40,61 @@ public sealed class LiveSessionLifecycleHostTests host.DetachSession(sessionB); } + /// Campaign CC slice CC4: / + /// forward to + /// the bindings' new optional delegates. + [Fact] + public void CharacterCreatedAndCreationFailed_ForwardToTheOptionalBindings() + { + var calls = new List(); + var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings( + Bind: session => CreateBinding(session, calls), + Reset: _ => { }, + Connecting: (_, _, _) => { }, + Connected: () => { }, + Roster: _ => { }, + Selected: _ => { }, + Entered: _ => { }, + CharacterCreated: identity => calls.Add($"created:{identity.Guid:X8}:{identity.Name}"), + CreationFailed: rejection => calls.Add($"failed:{rejection.Reason}"))); + + host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(0x50000001u, "Toon")); + host.ApplyCreationFailed(new RuntimeCharacterCreationRejection( + 3u, + AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse, + "NameInUse", + "Toon")); + + Assert.Equal(["created:50000001:Toon", "failed:NameInUse"], calls); + } + + /// The two delegates default to — every + /// construction site that predates CC4 keeps compiling and behaves as a + /// no-op, matching 's + /// own default-interface no-op. + [Fact] + public void CharacterCreatedAndCreationFailed_DefaultToNoOp_WhenBindingsOmitThem() + { + var calls = new List(); + var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings( + Bind: session => CreateBinding(session, calls), + Reset: _ => { }, + Connecting: (_, _, _) => { }, + Connected: () => { }, + Roster: _ => { }, + Selected: _ => { }, + Entered: _ => { })); + + host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(1u, "Toon")); + host.ApplyCreationFailed(new RuntimeCharacterCreationRejection( + 3u, + AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse, + "NameInUse", + "Toon")); + + Assert.Empty(calls); + } + [Fact] public void FailedBindingFactoryDoesNotClaimTheHost() { From 1774d8b29847ab5ee5f91fc890d82cdd7fdbabef Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 17:51:14 +0200 Subject: [PATCH 089/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC6a=20?= =?UTF-8?q?review=20fix=20round=20=E2=80=94=20F1-F12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CC6a dual-lens review (architectural PASS with reservations, retail fidelity PASS with reservations, merge after F1/F2/F3). F1 (BLOCKING) - AlternateSetup/setupId tested the wrong sentinel (0) instead of retail's INVALID_DID (0xFFFFFFFF, CharGenState::GetSetupID @0x005C5B22). A hair style storing that value would have been adopted as a literal Setup id, nulling Get and killing the whole preview. Fixed both sites with a new InvalidDid constant; added two hand-built tests plus an installed-DAT sweep of every hair style across all 26 heritage/gender combinations (869 selections, zero unresolved Setup ids). F2 (BLOCKING) - TS-82's register row, ChargenClothingTable.cs's doc, and the plan's ledger row all understated Undead's measured clothing-coverage gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside. Corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) - the palette-math "three independent sources" claim overcounted: ACViewer's ClothingTableList.xaml.cs:97 computes a different expression for a different problem, and its vendored PaletteSet.cs is ACE's own file, not an independent implementation. Rewrote the evidence paragraph in ChargenPalSetMath.cs to the two sources that actually hold (decomp control flow + ACE's "Taken from acclient.c" port). F4 (MEDIUM) - ChargenPreviewEntityBuilder.TryBuild did unlocked dat reads; DatCollection is not thread-safe and every sibling dat-touching resolver in this layer takes a shared datLock. Added a required datLock parameter; every dat read now happens inside one lock, mirroring RetailPaperdollPoseApplicator.Apply's shape. F5 (LOW) - noted the pre-existing Streaming.LandblockBuildFactoryTests timing flake in the ledger so a future session doesn't chase it. F6 (LOW) - fixed ChargenPreviewCamera.cs's rotation doc, which cited a nonexistent identifier in a dimensionally-wrong expression; corrected to retail's actual DoRotation @0x0047CAC7 per-tick formula. F7 (LOW-MEDIUM) - the TS-82 measurement was WriteLine-only; pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders). Kept the existing env-gated skip pattern (confirmed house convention). F8 (LOW) - the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop returns immediately on a miss (~0x005A7B32), aborting every remaining choice in that garment. Changed continue to break; added a test proving a subsequent present PalSet is correctly not applied. F9 (LOW) - fixed three dangling doc references (the method is TryCompose). F10 (LOW) - the packed (byte)(range/8) narrowing was unchecked; a real NumColors of 2048 happened to wrap to the correct "whole palette" 0 sentinel by unchecked-cast accident. Replaced with explicit PackOffset/ PackNumColors helpers that document the 2048->0 equivalence deliberately and throw on any other unrepresentable shape. F11/F12 (LOW, CC6b scope) - noted in the plan's CC6b row: the second m_alternateSetupID override source is unmodelled, and a shared RetailHeldPose helper is worth extracting before a fourth consumer. Test counts: Core.Tests 4772/1 skip (+5), Content.Tests 147/0 (+1), App.Tests 5121/6 skips (unchanged; F5's named flake did not reproduce) - zero failures, full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 4 +- .../2026-08-15-character-creation-campaign.md | 6 +- .../Rendering/ChargenPreviewCamera.cs | 10 +- .../Rendering/ChargenPreviewEntityBuilder.cs | 130 ++++++++----- .../CharGen/ChargenAppearanceFactory.cs | 95 +++++++-- .../CharGen/ChargenAppearanceSelection.cs | 2 +- .../CharGen/ChargenClothingTable.cs | 46 +++-- src/AcDream.Core/CharGen/ChargenPalSetMath.cs | 30 +-- .../ChargenPreviewEntityBuilderTests.cs | 6 +- ...argenAppearanceCatalogInstalledDatTests.cs | 182 ++++++++++++++++-- .../CharGen/ChargenAppearanceFactoryTests.cs | 170 ++++++++++++++++ 11 files changed, 561 insertions(+), 120 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1d9d05d4..1f0370f4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -389,12 +389,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 50 active rows (TS-83 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview holds a static rest-pose final frame instead of retail's live 30fps idle loop, explicitly staged for CC6b to retire; TS-82 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) to leave Undead's default headgear/trousers/footwear preview unclothed; TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 50 active rows (TS-83 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview holds a static rest-pose final frame instead of retail's live 30fps idle loop, explicitly staged for CC6b to retire; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | TS-83 | Chargen 3D preview (Campaign CC slice CC6a foundation): the preview holds a STATIC final-frame rest pose (`ChargenPreviewEntityBuilder.ApplyHeldPose`, retail's `m_didAnimationRest` DID resolution) instead of retail's live 30fps idle loop (`gmCG3DView`'s `m_didAnimation`/`m_didAnimArray` family, driven via `set_sequence_animation`). Deliberately staged, not discovered late: the campaign plan's own CC6 slice row names this exact split ("CC6a static-pose preview... register row for the missing idle loop, CC6b idle animation... retire the row"). | `src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs` (`ApplyHeldPose`); `src/AcDream.App/Rendering/ChargenPreviewRenderer.cs` | Explicitly staged per `docs/plans/2026-08-15-character-creation-campaign.md`'s CC6 slice split; the identical held-pose technique is the paperdoll's own PERMANENT (not staged) design (`RetailPaperdollPoseApplicator.Apply`), so the mechanism itself is proven, only the "hold forever vs. play then hold" choice is temporary here. | The chargen preview shows a motionless character instead of retail's idle sway/breathing loop — cosmetic only; does not affect the composed appearance data (setup id, palette, part/texture overrides) CC6b's page will bind to. | `gmCG3DView` ctor + `::Update @ 0x004EE9D0` (`m_didAnimation`/`m_didAnimArray`/`m_didAnimationRest` DID assignments, pseudo-C ~0x004EE7C6-0x004EE995); `CreatureMode::set_sequence_animation` (idle-loop playback entry point, not yet located precisely — CC6b to find) | -| TS-82 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`: the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default headgear/trousers/footwear choices (both genders) have NO base-effect entry for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage) rather than pervasive. | Undead's default headgear/trousers/footwear preview renders the bare body mesh for those three slots (no clothing part/texture override applied, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | +| TS-82 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | | TS-76 | Five Character-tab rows have no acdream consumer at all (research doc §4.2's own "state-only, no consumer" list, narrowed to the ids NOT already closed by Campaign OP's Group-C re-points): "Display 3D Tooltips" (`ShowTooltips`), "Side By Side Vitals" (`SideBySideVitals`), "Display Spell Durations" (`SpellDuration`), "Advanced Combat Interface" (`AdvancedCombatUI`), "Stay in Chat Mode After Sending a Message" (`StayInChatMode`) — retail renders 3D item tooltips, an alternate side-by-side vitals layout, remaining-duration overlays on enchantment icons, an expanded combat panel, and a chat-input-stays-open behavior respectively; acdream has none of the four rendering surfaces and no chat-input-close-on-send behavior to gate in the first place. | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (the rows wire+store only) | Each needs a real UI/behavior feature built before the option means anything — inventing a stand-in now would be exactly the workaround CLAUDE.md forbids. | Toggling any of the five writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes. | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; `EffectInfoRegion::Update @0x004f1c00`; `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `ChatInterface::HandleEnterKey @0x004f52d0`; `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index fd27cd37..bea19fab 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -253,6 +253,8 @@ the user gate. | 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 | — | | | | | CC5 | — | | | | -| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | single commit, HEAD of `campaign-cc6a` | PENDING (Opus dual-lens not yet run this session) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — three-way agreement, no guessing:** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c"), ACViewer's identical `ClothingTableList.xaml.cs:97` slider math, AND the decomp's own control-flow shape. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default headgear/trousers/footwear choices genuinely miss `ClothingBaseEffects` coverage for Undead's own live body Setup on both genders — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests:** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (19 hand-built-fixture cases covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity). Final counts this session: Core.Tests 4767/1 skip, Content.Tests 146/0 skips, App.Tests 5121/6 skips — all pre-existing skips, zero failures, full solution Release build green. | -| CC6b | — | | | | +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | single commit, HEAD of `campaign-cc6a` (plus a same-session review fix-round commit, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). + +**Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-82's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-82 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | +| CC6b | NOT STARTED | | | **MUST-COVER, carried from the CC6a review fix round (F11/F12):** (1) retail's SECOND Setup-override source — `gmCG3DView`'s `m_alternateSetupID`, set from the Appearance page's option checkbox (Penumbraen crown variant `@0x004DFB3F`, Undead no-flame variant `@0x004E0C54`), takes precedence over the hair style's `AlternateSetup` at `gmCG3DView::Update`'s own resolution (`@0x004EEA51`) — CC6a's factory only ports the hair-style source; this second source is completely unmodelled and needs its own citation-backed port + register-row bookkeeping if CC6b doesn't fully close it. (2) Before adding a FOURTH consumer of the "resolve a rest-pose DID via master-map slot 7, load its Animation, hold the final frame" algorithm (paperdoll's `RetailPaperdollPoseApplicator`, CC6a's `ChargenPreviewEntityBuilder.ApplyHeldPose`/`ResolvePoseDid` are the second and third), extract a shared `RetailHeldPose` helper rather than copying it a third time. | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs index eeb907ad..138dab5f 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -93,9 +93,13 @@ public sealed class ChargenPreviewCamera : ICamera /// (gmCGAppearancePage::m_dRotationPerSec, ctor pseudo-C /// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000, /// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly, - /// no reconstruction needed). Consumed by CC6b's rotation controller as - /// 360f / RotationDegreesPerSecond — NOT applied here; see this - /// class's own doc comment on why rotation is not a camera concern. + /// no reconstruction needed). Retail's own per-tick formula + /// (gmCGAppearancePage::DoRotation @ 0x0047CA80, pseudo-C + /// ~0x0047CAC7): deltaDegrees = ((now - lastRotateTime) / + /// RotationSecondsPerRevolution) * 360 — CC6b's rotation controller + /// consumes this constant in exactly that shape, not as a + /// degrees-per-second rate. NOT applied here; see this class's own doc + /// comment on why rotation is not a camera concern. /// public const float RotationSecondsPerRevolution = 3.0f; diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs index 3b79de88..0717430a 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -60,74 +60,85 @@ internal static class ChargenPreviewEntityBuilder /// failure shape treats /// as "drop this spawn"). /// + /// + /// Shared exclusion object for every dat read this method performs. + /// DatCollection is NOT thread-safe (see + /// claude-memory/feedback_phase_a1_hotfix_saga.md) — every other + /// dat-touching renderer/resolver in this layer + /// (RetailPaperdollPoseApplicator, PlayerModeController, + /// DatProjectileSetupResolver, EquippedChildRenderController) + /// takes the SAME object datLock the composition root threads + /// through as RuntimeOptions/d.DatLock; callers MUST pass + /// that same shared instance, not a private lock, or this method's reads + /// race every other consumer's. + /// public static WorldEntity? TryBuild( IDatReaderWriter dats, IAnimationLoader animations, ChargenAppearanceResult appearance, uint heritageId, - Quaternion heading) + Quaternion heading, + object datLock) { ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(animations); ArgumentNullException.ThrowIfNull(appearance); + ArgumentNullException.ThrowIfNull(datLock); - Setup? setup = dats.Get(appearance.SetupId); - if (setup is null) - return null; + List meshRefs; + uint setupId = appearance.SetupId; + PaletteOverride? paletteOverride; + PartOverride[] partOverrides; - var flattened = new List(SetupMesh.Flatten(setup)); - - foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges) + // Every dat read this method performs — the Setup fetch, the held- + // pose animation resolution, the per-part GfxObj drawable checks, + // and the texture-change surface resolution — happens inside this + // one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve + // everything under lock, then do pure processing" shape. + lock (datLock) { - if (change.PartIndex < flattened.Count) - flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); - } + Setup? setup = dats.Get(setupId); + if (setup is null) + return null; - ApplyHeldPose(dats, animations, setup, heritageId, flattened); + var flattened = new List(SetupMesh.Flatten(setup)); - Dictionary>? surfaceOverrides = - ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges); - - var meshRefs = new List(flattened.Count); - for (int partIndex = 0; partIndex < flattened.Count; partIndex++) - { - MeshRef part = flattened[partIndex]; - if (dats.Get(part.GfxObjId) is null) - continue; // matches DatLiveEntityProjectionMaterializer's drawable filter. - - IReadOnlyDictionary? overrides = null; - if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) - overrides = perPart; - - meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); - } - if (meshRefs.Count == 0) - return null; - - PaletteOverride? paletteOverride = null; - if (appearance.ObjDesc.SubPalettes.Count > 0) - { - var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count]; - for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++) + foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges) { - ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i]; - ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors); + if (change.PartIndex < flattened.Count) + flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); } - paletteOverride = new PaletteOverride(appearance.BasePaletteId, ranges); - } - var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count]; - for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++) - { - ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i]; - partOverrides[i] = new PartOverride(change.PartIndex, change.PartId); + ApplyHeldPose(dats, animations, setup, heritageId, flattened); + + Dictionary>? surfaceOverrides = + ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges); + + meshRefs = new List(flattened.Count); + for (int partIndex = 0; partIndex < flattened.Count; partIndex++) + { + MeshRef part = flattened[partIndex]; + if (dats.Get(part.GfxObjId) is null) + continue; // matches DatLiveEntityProjectionMaterializer's drawable filter. + + IReadOnlyDictionary? overrides = null; + if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) + overrides = perPart; + + meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + } + if (meshRefs.Count == 0) + return null; + + paletteOverride = BuildPaletteOverride(appearance); + partOverrides = BuildPartOverrides(appearance); } return new WorldEntity { Id = PreviewRenderId, ServerGuid = PreviewServerGuid, - SourceGfxObjOrSetupId = appearance.SetupId, + SourceGfxObjOrSetupId = setupId, Position = Vector3.Zero, Rotation = heading, MeshRefs = meshRefs, @@ -137,6 +148,35 @@ internal static class ChargenPreviewEntityBuilder }; } + /// No dat access — pure projection of the already-composed + /// ObjDesc's subpalettes, safe to call outside datLock. + private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance) + { + if (appearance.ObjDesc.SubPalettes.Count == 0) + return null; + + var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count]; + for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++) + { + ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i]; + ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors); + } + return new PaletteOverride(appearance.BasePaletteId, ranges); + } + + /// No dat access — pure projection, safe to call outside + /// datLock. + private static PartOverride[] BuildPartOverrides(ChargenAppearanceResult appearance) + { + var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count]; + for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++) + { + ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i]; + partOverrides[i] = new PartOverride(change.PartIndex, change.PartId); + } + return partOverrides; + } + /// /// Overwrites every part's transform from the resolved rest pose's /// FINAL frame — same "hold the settled last frame at zero frame rate" diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs index c091471a..d2fa1d29 100644 --- a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs +++ b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs @@ -1,7 +1,7 @@ namespace AcDream.Core.CharGen; /// -/// The resolved render description +/// The resolved render description /// produces: a body Setup id plus the composed ObjDesc a mesh builder applies /// to it (CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0 is /// retail's equivalent apply step). The three diagnostic lists let callers @@ -11,11 +11,15 @@ namespace AcDream.Core.CharGen; /// /// The body Setup dat id (0x02......) to build the preview mesh from — /// gender.SetupId, overridden by the selected hair style's -/// AlternateSetup when nonzero (Gear Knight / Undead / Tumerok body -/// variants), falling back to -/// when both are zero (retail: CPhysicsObj::makeObject(setupId)'s own -/// HUMAN_SETUP_ID fallback, gmCG3DView ctor pseudo-C ~0x004EE79D and -/// gmCG3DView::Update ~0x004EEA61). +/// AlternateSetup when it is neither 0 nor retail's INVALID_DID +/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), falling back +/// to when the resolved +/// id is 0 OR INVALID_DID (retail: CharGenState::GetSetupID @ +/// 0x005C5B22 and gmCG3DView::Update's own check at +/// ~0x004EEA51/0x004EEA5F both test against INVALID_DID, not zero — +/// acclient.h:39909 types the field as IDClass, whose "unset" +/// value is 0xFFFFFFFF; CPhysicsObj::makeObject(setupId)'s own +/// HUMAN_SETUP_ID fallback, gmCG3DView ctor pseudo-C ~0x004EE79D). /// /// /// gender.BasePaletteId (retail Sex_CG.BasePalette) — the @@ -28,7 +32,7 @@ namespace AcDream.Core.CharGen; /// /// /// The composed subpalette/texture/part-swap deltas, in retail's exact -/// application order (see ). +/// application order (see ). /// public sealed record ChargenAppearanceResult( uint SetupId, @@ -86,6 +90,18 @@ public static class ChargenAppearanceFactory /// public const uint HumanSetupId = 0x02000001u; + /// + /// Retail's IDClass "unset" sentinel (INVALID_DID, + /// 0xFFFFFFFF — acclient.h:39909). CharGenState::GetSetupID @ + /// 0x005C5B22 and gmCG3DView::Update's own checks + /// (~0x004EEA51/0x004EEA5F) both test a Setup id against THIS value, not + /// zero — a hair style whose AlternateSetup field happens to + /// store this sentinel must be treated as "no override," exactly like + /// zero, or the factory would hand a bogus Setup id to + /// Get<Setup> and produce no preview at all. + /// + private const uint InvalidDid = 0xFFFFFFFFu; + /// /// Skin subpalette overlay range, retail's hard-coded literal at /// gmCG3DView::Update ~0x004EF066-0x004EF07E: real byte offset 0, @@ -151,10 +167,10 @@ public static class ChargenAppearanceFactory && selection.HairStyle < (uint)gender.HairStyles.Count) { hairStyle = gender.HairStyles[(int)selection.HairStyle]; - if (hairStyle.AlternateSetup != 0) + if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid) setupId = hairStyle.AlternateSetup; } - if (setupId == 0) + if (setupId == 0 || setupId == InvalidDid) setupId = HumanSetupId; // ── 2. ObjDesc accumulation, retail's exact append order ─────── @@ -322,15 +338,22 @@ public static class ChargenAppearanceFactory uint paletteTemplateId = clothingColors[(int)colorIndex]; if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template)) - return; // retail: hash miss on the palette-template lookup is a silent no-op. + return; // retail: hash miss on the OUTER palette-template lookup is a silent no-op. foreach (ChargenClothingSubPaletteChoice choice in template.Choices) { ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId); if (palSet is null) { + // Retail's own inner loop (ClothingTable::BuildObjDesc + // ~0x005A7B24-0x005A7BD3) returns 0 IMMEDIATELY when + // DBObj::Get fails for one subpalEffect entry's PalSet + // (~0x005A7B32) — aborting every REMAINING choice in this + // same garment's palette template, not merely skipping the + // failed one. `break`, not `continue`, matches that; the + // miss is still recorded so callers can see it happened. missingPalSets.Add(choice.PalSetId); - continue; + break; } int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade); @@ -342,9 +365,55 @@ public static class ChargenAppearanceFactory { subPalettes.Add(new ChargenSubPalette( paletteId, - (byte)(range.Offset / 8), - (byte)(range.NumColors / 8))); + PackOffset(range.Offset), + PackNumColors(range.NumColors))); } } } + + /// + /// Converts a real (unpacked) clothing subpalette offset into + /// 's packed *8 on-disk unit. Throws + /// rather than silently truncating on a shape we've never seen and + /// don't know how to represent losslessly (guards against the + /// unchecked-narrowing footgun a plain (byte)(value / 8) cast + /// would otherwise hide). + /// + private static byte PackOffset(uint realOffset) + { + if (realOffset % 8u != 0 || realOffset > 2040u) + { + throw new ArgumentOutOfRangeException( + nameof(realOffset), + realOffset, + "Clothing subpalette range offset does not fit the packed *8 byte " + + "convention (expected a multiple of 8 in [0, 2040])."); + } + return (byte)(realOffset / 8u); + } + + /// + /// Same packing as , plus retail's own explicit + /// "whole palette" sentinel: a packed NumColors of 0 means "the + /// entire palette" ('s + /// doc: "Length=0 is a sentinel meaning entire palette... defaulting to + /// 256*8"). A real count of exactly 2048 (256*8) IS that same value + /// spelled out in real units, so it packs to 0 BY DESIGN — not because + /// an unchecked (byte) cast happens to wrap 256 back to 0. + /// + private static byte PackNumColors(uint realNumColors) + { + if (realNumColors == 2048u) + return 0; + if (realNumColors % 8u != 0 || realNumColors > 2040u) + { + throw new ArgumentOutOfRangeException( + nameof(realNumColors), + realNumColors, + "Clothing subpalette range color count does not fit the packed *8 byte " + + "convention (expected a multiple of 8 in [0, 2040], or exactly 2048 " + + "for the whole-palette sentinel)."); + } + return (byte)(realNumColors / 8u); + } } diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs index efe2d421..f26b6427 100644 --- a/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs +++ b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs @@ -2,7 +2,7 @@ namespace AcDream.Core.CharGen; /// /// The fourteen style/color indices plus the six f64 shades -/// needs to build a preview +/// needs to build a preview /// description — field-for-field the same shape as CC3's /// AcDream.Runtime.Session.RuntimeCharacterCreationAppearance (and, /// through it, CharacterCreate.Appearance's wire fields), kept as a diff --git a/src/AcDream.Core/CharGen/ChargenClothingTable.cs b/src/AcDream.Core/CharGen/ChargenClothingTable.cs index f46227f9..3601bf48 100644 --- a/src/AcDream.Core/CharGen/ChargenClothingTable.cs +++ b/src/AcDream.Core/CharGen/ChargenClothingTable.cs @@ -85,31 +85,35 @@ public sealed record ChargenClothingBaseEffect( /// Penumbraen, Undead skeleton/zombie, Anakshay) when /// has no direct entry for the requested /// body Setup. CC6a's composer looks up -/// directly and skips a slot's part/texture contribution on a miss -/// (matching retail's own "hash miss → BuildObjDesc returns failure, caller -/// does not check it, ObjDesc keeps whatever it already had" behavior) -/// rather than porting the substitution chain. The installed-DAT catalog -/// test (ChargenAppearanceCatalogInstalledDatTests) MEASURED this -/// directly across all 26 heritage/gender combinations rather than assuming -/// it: for the 9 standard heritages where retail's own UI actually shows -/// clothing controls (everything except Gear Knight and the two Olthoi -/// variants, which retail hides the clothes button for entirely — -/// gmCGAppearancePage::Update @ 0x0047E8F0's +/// directly and skips a slot's part/texture contribution on a miss (this is +/// the OUTER lookup — ClothingTable::_cloBaseHash — whose retail +/// miss behavior is genuinely a no-op the caller never checks; the SEPARATE +/// inner per-choice PalSet lookup inside the same function's subpalette loop +/// has its own, stricter, abort-on-miss behavior — see +/// ChargenAppearanceFactory.ComposeClothingSlot's own doc, ported +/// faithfully there) rather than porting the Setup-substitution chain. The +/// installed-DAT catalog test (ChargenAppearanceCatalogInstalledDatTests) +/// MEASURED this directly across all 26 heritage/gender combinations rather +/// than assuming it: for the 9 standard heritages where retail's own UI +/// actually shows clothing controls (everything except Gear Knight and the +/// two Olthoi variants, which retail hides the clothes button for entirely +/// — gmCGAppearancePage::Update @ 0x0047E8F0's /// m_pClothesButton->SetVisible(0) branches for /// mHeritageGroup == 6 and == 0xc || == 0xd), the default /// gear choices resolve against their own body Setup with ZERO missing /// coverage. Undead IS a real gap — retail DOES show clothing -/// controls for Undead, but its default headgear/trousers/footwear choices -/// have no entry for either gender's -/// live Setup id (measured: 4 of 4 non-shirt slots miss, on both genders), -/// because Undead's live body Setup IS one of the skeleton/zombie variants -/// the un-ported substitution chain exists to redirect. A live preview for -/// Undead will therefore render its default headgear/trousers/footwear -/// choice with NO part/texture override applied (the underlying body shows -/// through unclothed for those slots) until the substitution chain — or an -/// equivalent per-heritage default-clothing-setup mapping — lands. Filed as -/// a known CC6a limitation for CC6b/a follow-up rather than silently -/// "confirmed unreachable." +/// controls for Undead, and MEASURED coverage is missing for ALL FOUR +/// clothing slots (headgear, trousers, shirt, AND footwear — not just three +/// of the four), on both genders: neither gender's live body Setup has a +/// entry in any of its four default gear +/// choices' clothing tables, because Undead's live body Setup IS one of the +/// skeleton/zombie variants the un-ported substitution chain exists to +/// redirect. A live preview for Undead will therefore render its default +/// clothing selection with NO part/texture override applied on any of the +/// four slots (the underlying body shows through unclothed) until the +/// substitution chain — or an equivalent per-heritage default-clothing-setup +/// mapping — lands. Filed as a known CC6a limitation for CC6b/a follow-up +/// rather than silently "confirmed unreachable." /// /// public sealed record ChargenClothingTable( diff --git a/src/AcDream.Core/CharGen/ChargenPalSetMath.cs b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs index 68cdb042..2076013b 100644 --- a/src/AcDream.Core/CharGen/ChargenPalSetMath.cs +++ b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs @@ -5,17 +5,25 @@ namespace AcDream.Core.CharGen; /// (PalSet::GetPaletteID @ 0x005AC570, invoked from /// gmCG3DView::Update @ 0x004EE9D0 for the skin/hair subpalette /// build and from ClothingTable::BuildObjDesc @ 0x005A7900 for every -/// clothing-slot dye choice). The decompiled body is FPU-elided (the x87 -/// bounds-compare against 0.0/1.0 and the truncating _ftol2() cast -/// lose their operands to the decompiler), but ACE's -/// ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID carries the -/// explicit comment "Taken from acclient.c (PalSet::GetPaletteID)" with the -/// exact formula below — corroborated by the decomp's own control-flow -/// shape (a two-sided FPU compare consistent with a [0,1] bounds -/// check, then one truncating cast) and independently by ACViewer's -/// ClothingTableList.xaml.cs:97 UI slider, which reimplements the -/// identical (count - 0.000001) * shade expression for its own shade -/// preview. Three independent sources agree. +/// clothing-slot dye choice). The decompiled body is genuinely FPU-elided — +/// the _ftol2() truncating-cast operand is lost to the decompiler, +/// and can only be read as "some product of -ish and +/// -ish operands" from the surrounding x87 stack +/// traffic — but the decomp's own control-flow SHAPE is still verifiable +/// independent of that lost operand: a two-sided FPU compare at +/// 0x005AC5A0 gating on >= 0.0, consistent with a +/// [0,1] shade bounds check before the cast. What resolves the +/// elided operand is ACE's ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID, +/// which carries the explicit comment "Taken from acclient.c +/// (PalSet::GetPaletteID)" against the exact formula below. That is TWO +/// sources (decomp control flow + ACE's cited port), not three: the +/// PaletteSet.cs file present in the vendored ACViewer checkout is +/// ACE's own file, not an independent reimplementation, and ACViewer's +/// ClothingTableList.xaml.cs:97 UI slider computes a DIFFERENT +/// expression for a DIFFERENT problem (mapping a shade back to a slider tick +/// position against Shades.Maximum, i.e. count-1, not +/// count) — neither corroborates this formula and both are dropped +/// from the evidence chain here. /// public static class ChargenPalSetMath { diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs index 8a3f45d3..656b27eb 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -53,7 +53,7 @@ public sealed class ChargenPreviewEntityBuilderTests var animations = new RetailAnimationLoader(adapter); var entity = ChargenPreviewEntityBuilder.TryBuild( - adapter, animations, appearance, heritageId: 1u, Quaternion.Identity); + adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object()); Assert.NotNull(entity); Assert.NotEmpty(entity!.MeshRefs); @@ -85,7 +85,7 @@ public sealed class ChargenPreviewEntityBuilderTests ClothingTablesMissingBaseEffectForSetup: []); var entity = ChargenPreviewEntityBuilder.TryBuild( - adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity); + adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object()); Assert.Null(entity); } @@ -115,7 +115,7 @@ public sealed class ChargenPreviewEntityBuilderTests Assert.True(composed); var entity = ChargenPreviewEntityBuilder.TryBuild( - adapter, animations, appearance, heritageId: 12u, Quaternion.Identity); + adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object()); // Just proves the Olthoi branch doesn't throw / silently fall through to // "no mesh" — the exact pose DID differs internally (0x10000011 vs diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs index b4d08248..98c0d0a4 100644 --- a/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs +++ b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs @@ -13,19 +13,52 @@ namespace AcDream.Content.Tests.CharGen; /// everywhere, mid shade" selection and asserts it resolves with no missing /// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance /// bar ("every heritage/gender's default selection resolves to a complete -/// description with no missing dat ids"). Also records (without asserting -/// zero — see the class doc on 's -/// deliberate scope cut) how many clothing slots have no -/// ClothingBaseEffects entry for their own gender's body Setup, so a -/// future session can see at a glance whether CC6a's decision to skip -/// retail's Setup-substitution fallback chain ever actually costs -/// coverage on the real dat. +/// description with no missing dat ids"). ALSO pins the TS-82 measurement +/// with real assertions (not WriteLine-only diagnostics, per the CC6a +/// review fix round F7): the nine standard heritages with clothing UI shown +/// resolve zero ClothingBaseEffects gaps, and Undead resolves +/// EXACTLY the four measured gaps on both genders — see the class doc on +/// 's deliberate scope cut. +/// +/// Env-gated skip (house pattern, matched from +/// ChargenTableReaderInstalledDatTests/ContentConformanceDats): +/// returns green with a console SKIP note when no installed dat directory is +/// configured, rather than a true xUnit Skipped status — no other Content +/// installed-DAT test in this project uses Assert.Skip, so this stays +/// consistent with the rest of the suite rather than introducing a new +/// convention. /// public sealed class ChargenAppearanceCatalogInstalledDatTests { private readonly ITestOutputHelper _out; public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output; + // ACE ACE.Entity.Enum.HeritageGroup ids. Gearknight (6)/Olthoi (12)/ + // OlthoiAcid (13) are deliberately not named here — see the WriteLine-only + // comment in the loop below for why they carry no pinned expectation. + private const uint TumerokId = 7u; + private const uint UndeadId = 11u; + + /// + /// The 9 standard heritages whose UI actually shows clothing controls + /// AND whose default gear resolves with zero ClothingBaseEffects + /// gaps (measured, not the full "clothing UI shown" set — Undead is + /// ALSO clothing-UI-shown but is the one real gap, asserted separately + /// below). Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Tumerok/Lugian/ + /// Empyrean/Penumbraen = every heritage id 1-10 except Gearknight (6). + /// + private static readonly uint[] StandardZeroGapHeritageIds = [1u, 2u, 3u, 4u, 5u, TumerokId, 8u, 9u, 10u]; + + /// + /// Measured (installed EoR dat, both genders, identical order): Undead's + /// default headgear/trousers/shirt/footwear choices' clothing tables, in + /// the factory's own Headgear→Trousers→Shirt→Footwear composition order. + /// ALL FOUR slots miss — not "headgear/trousers/footwear" (a three-slot + /// undercount an earlier draft of this row stated in error). + /// + private static readonly uint[] UndeadMeasuredMissingClothingTableIds = + [0x10000009u, 0x100000F9u, 0x10000001u, 0x10000007u]; + private static string? ResolveDatDir() { string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); @@ -55,8 +88,8 @@ public sealed class ChargenAppearanceCatalogInstalledDatTests var catalog = new ChargenAppearanceCatalog(adapter); int composed = 0; - int absentBaseEffectTotal = 0; var missingSummaries = new List(); + var baseEffectGapFailures = new List(); foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) { @@ -79,23 +112,134 @@ public sealed class ChargenAppearanceCatalogInstalledDatTests + $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]"); } - absentBaseEffectTotal += result.ClothingTablesMissingBaseEffectForSetup.Count; - if (result.ClothingTablesMissingBaseEffectForSetup.Count > 0) + _out.WriteLine( + $"heritage={heritage.Name} (0x{heritage.HeritageId:X}) gender={genderKey} setup=0x{result.SetupId:X8}: " + + $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no " + + "ClothingBaseEffects entry for this body setup " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + + // TS-82's pinned measurement — real assertions, not WriteLine-only. + if (StandardZeroGapHeritageIds.Contains(heritage.HeritageId)) { - _out.WriteLine( - $"heritage={heritage.Name} gender={genderKey} setup=0x{result.SetupId:X8}: " - + $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no " - + "ClothingBaseEffects entry for this body setup " - + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + if (result.ClothingTablesMissingBaseEffectForSetup.Count != 0) + { + baseEffectGapFailures.Add( + $"heritage={heritage.Name} gender={genderKey}: expected ZERO ClothingBaseEffects " + + $"gaps (a standard heritage with clothing UI shown), measured " + + $"{result.ClothingTablesMissingBaseEffectForSetup.Count}: " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + } + } + else if (heritage.HeritageId == UndeadId) + { + if (!result.ClothingTablesMissingBaseEffectForSetup.SequenceEqual(UndeadMeasuredMissingClothingTableIds)) + { + baseEffectGapFailures.Add( + $"heritage=Undead gender={genderKey}: expected EXACTLY " + + $"[{string.Join(",", UndeadMeasuredMissingClothingTableIds.Select(id => $"0x{id:X8}"))}], measured " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + } + } + // Gearknight/Olthoi/OlthoiAcid: retail hides the clothing UI + // entirely for these three (gmCGAppearancePage::Update + // @0x0047E8F0's SetVisible(0) branches), so a real chargen + // selection never reaches this composer's clothing slots for + // them — no pinned expectation either way, WriteLine above + // is diagnostic only. + } + } + + _out.WriteLine($"composed {composed} heritage/gender selections."); + Assert.True( + missingSummaries.Count == 0, + "Missing dat ids found:\n" + string.Join('\n', missingSummaries)); + Assert.True( + baseEffectGapFailures.Count == 0, + "TS-82 measurement drifted from its pinned expectation:\n" + string.Join('\n', baseEffectGapFailures)); + Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}."); + } + + /// + /// CC6a review fix round F1: retail's Setup-id "unset" sentinel is + /// INVALID_DID (0xFFFFFFFF), not 0 + /// (CharGenState::GetSetupID @ 0x005C5B22). Sweeps EVERY hair + /// style of all 26 heritage/gender combinations and asserts the composed + /// SetupId always resolves to a REAL installed Setup dat entry — proving + /// neither sentinel value, wherever a hair style's AlternateSetup + /// field happens to store one, ever reaches Get<Setup> as a + /// literal id. + /// + [Fact] + public void EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.NotEmpty(options.HeritagesById); + var catalog = new ChargenAppearanceCatalog(adapter); + + int sweptHairStyles = 0; + var unresolvedSetups = new List(); + + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey) + { + for (uint hairStyleIndex = 0; hairStyleIndex < (uint)gender.HairStyles.Count; hairStyleIndex++) + { + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = hairStyleIndex, + SkinShade = 0.5, + }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritage.HeritageId, genderKey, selection, + catalog, catalog, out ChargenAppearanceResult result); + Assert.True(ok); + sweptHairStyles++; + + if (adapter.Get(result.SetupId) is null) + { + unresolvedSetups.Add( + $"heritage={heritage.Name} gender={genderKey} hairStyle={hairStyleIndex}: " + + $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup"); + } + } + + // Every gender is swept even with zero hair styles (still + // exercises the "no hair style selected" default-setup path). + if (gender.HairStyles.Count == 0) + { + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritage.HeritageId, genderKey, + ChargenAppearanceSelection.Default with { SkinShade = 0.5 }, + catalog, catalog, out ChargenAppearanceResult result); + Assert.True(ok); + sweptHairStyles++; + if (adapter.Get(result.SetupId) is null) + { + unresolvedSetups.Add( + $"heritage={heritage.Name} gender={genderKey} (no hair styles): " + + $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup"); + } } } } - _out.WriteLine($"composed {composed} heritage/gender selections; {absentBaseEffectTotal} absent-base-effect slots total."); + _out.WriteLine($"swept {sweptHairStyles} hair-style/no-hair-style selections across 26 heritage/gender combinations."); Assert.True( - missingSummaries.Count == 0, - "Missing dat ids found:\n" + string.Join('\n', missingSummaries)); - Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}."); + unresolvedSetups.Count == 0, + "Composed SetupId(s) that don't resolve to a real installed Setup:\n" + string.Join('\n', unresolvedSetups)); + Assert.True(sweptHairStyles > 26, $"Expected more than 26 swept selections (multiple hair styles per gender), got {sweptHairStyles}."); } /// diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs index b405a70f..8f4ffa7b 100644 --- a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs +++ b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs @@ -259,6 +259,49 @@ public sealed class ChargenAppearanceFactoryTests Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); } + /// + /// CC6a review fix round F1: retail's "unset" sentinel for a Setup id is + /// INVALID_DID (0xFFFFFFFF — CharGenState::GetSetupID @ + /// 0x005C5B22), not 0. A hair style whose AlternateSetup field + /// stores 0xFFFFFFFF must NOT be adopted as the body Setup id — before + /// this fix the factory would hand 0xFFFFFFFF straight to a caller's + /// Get<Setup>, which nulls, and the whole preview build + /// would fail silently. + /// + [Fact] + public void TryCompose_HairStyleAlternateSetupIsInvalidDid_IsTreatedAsUnsetNotAdopted() + { + ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: 0xFFFFFFFFu)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(BodySetupId, result.SetupId); // gender.SetupId, NOT the INVALID_DID sentinel. + } + + /// + /// Companion to : + /// the resolved Setup id can ALSO be stuck at INVALID_DID (rather than 0) + /// when the gender's own SetupId dat field happens to be + /// 0xFFFFFFFF — the fallback to + /// must catch that case too. + /// + [Fact] + public void TryCompose_GenderSetupIdIsInvalidDid_FallsBackToHumanSetupId() + { + ChargenGenderOptions gender = MakeGender() with { SetupId = 0xFFFFFFFFu }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(bodySetupId: 0xFFFFFFFFu); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); + } + [Fact] public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald() { @@ -368,6 +411,133 @@ public sealed class ChargenAppearanceFactoryTests Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); } + /// + /// CC6a review fix round F8: retail's inner subpalette loop + /// (ClothingTable::BuildObjDesc ~0x005A7B24-0x005A7BD3) returns 0 + /// IMMEDIATELY when a PalSet read fails for one choice (~0x005A7B32), + /// aborting every REMAINING choice in that garment's palette template — + /// not merely skipping the failed one and continuing. A two-choice + /// template with the FIRST choice's PalSet missing must therefore emit + /// NEITHER choice's subpalette, even though the second choice's own + /// PalSet is present and would resolve fine on its own. + /// + [Fact] + public void TryCompose_PalSetMissingMidLoop_AbortsRemainingChoicesInThatGarment() + { + const uint missingPalSetId = 0x0F00_00AAu; + const uint presentPalSetId = 0x0F00_00BBu; + + var firstChoice = new ChargenClothingSubPaletteChoice( + missingPalSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]); + var secondChoice = new ChargenClothingSubPaletteChoice( + presentPalSetId, [new ChargenClothingSubPaletteRange(160u, 8u)]); + var baseEffects = new Dictionary + { + [BodySetupId] = ChargenClothingBaseEffect.Empty, + }; + var templates = new Dictionary + { + [7u] = new ChargenClothingPaletteTemplate([firstChoice, secondChoice]), + }; + var table = new ChargenClothingTable(baseEffects, templates); + + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + clothing.Add(HeadgearClothingTableId, table); // override the shared fixture's single-choice table. + pal.Add(presentPalSetId, 0x0400_0055u); // deliberately NOT adding missingPalSetId. + + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, + HeadgearShade = 0.0, + }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(missingPalSetId, result.MissingPalSetIds); + // Real range (160, 8) would pack to (20, 1) if the second choice were + // (incorrectly) still applied after the first choice's miss. + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 20 && sp.NumColors == 1); + // Nothing from EITHER choice's own range landed. + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + /// + /// CC6a review fix round F10: a real dat NumColors of exactly + /// 2048 (256*8) is retail's own "whole palette" value spelled out in + /// real units — it packs to the byte 0 sentinel + /// ('s documented + /// "Length=0 means entire palette") EXPLICITLY, not via an unchecked + /// narrowing coincidence. + /// + [Fact] + public void TryCompose_ClothingRangeNumColorsIsWholePaletteSentinel_PacksToZeroExplicitly() + { + var choice = new ChargenClothingSubPaletteChoice( + 0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2048u)]); + var baseEffects = new Dictionary + { + [BodySetupId] = ChargenClothingBaseEffect.Empty, + }; + var table = new ChargenClothingTable( + baseEffects, + new Dictionary { [7u] = new([choice]) }); + + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + clothing.Add(HeadgearClothingTableId, table); + + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, + HeadgearShade = 0.0, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 0); + } + + /// + /// CC6a review fix round F10: a shape the packed *8 byte convention + /// cannot represent losslessly (not a multiple of 8, and not the 2048 + /// whole-palette sentinel) must THROW rather than silently truncate via + /// an unchecked (byte) cast. + /// + [Fact] + public void TryCompose_ClothingRangeDoesNotFitThePackedByteConvention_Throws() + { + var choice = new ChargenClothingSubPaletteChoice( + 0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2041u)]); // not a multiple of 8, not 2048. + var baseEffects = new Dictionary + { + [BodySetupId] = ChargenClothingBaseEffect.Empty, + }; + var table = new ChargenClothingTable( + baseEffects, + new Dictionary { [7u] = new([choice]) }); + + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + clothing.Add(HeadgearClothingTableId, table); + + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, + HeadgearShade = 0.0, + }; + + Assert.Throws(() => + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out _)); + } + [Fact] public void TryCompose_UnknownClothingTableId_IsRecordedAsMissingAndSkipped() { From ec854db045da39b9dccf103fb3a1c2e2110ce572 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 18:29:49 +0200 Subject: [PATCH 090/138] =?UTF-8?q?fix(app,runtime,headless):=20Campaign?= =?UTF-8?q?=20CC=20slice=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; } From 8add0667a5f7abaecf102b1007ac1b435dc6d56b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:04:04 +0200 Subject: [PATCH 091/138] =?UTF-8?q?fix(app,headless):=20Campaign=20CC=20sl?= =?UTF-8?q?ice=20CC4=20re-review=20round=20=E2=80=94=20R1=20arbiter=20+=20?= =?UTF-8?q?R2-R4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CC4 re-review returned NOT CLOSED: R1 (MEDIUM, blocking) is a new residual the F1 fix itself introduced, plus three LOW riders (R2, R3, R4). R1 — nulling UiRoot.FixedCanvasSize on chargen Close() stripped it from character-management, which stays active underneath and only sets the canvas on its own activation edge. Root cause (reviewer-named): two controllers writing one host-global with no owner. Fixed with the root-cause shape (reviewer's option (c)): UiRoot.DeclareFixedCanvas(owner, size)/RevokeFixedCanvas(owner), an owner-scoped arbiter — every declarer must agree on the canvas size (a mismatch throws instead of silently last-writer-wins), and the canvas nulls only once EVERY declarer has revoked. Both CharacterCreationUiController and CharacterManagementUi- Controller now declare/revoke instead of writing FixedCanvasSize directly; grepped for stragglers, none remain in production code (the raw setter stays public only for UiRootFixedCanvasTests' isolated scale-math coverage). New test (reviewer-specified): CharacterScreensFixedCanvasArbiterTests — two controllers sharing one UiRoot, proving the canvas stays set through chargen's Exit-confirm Close while char-management is still active, nulling only once char-management also deactivates, plus the original F1 defect's own covering case (both revoke together at world entry). R3 — HeadlessSessionHostTests.ContentLease_InstallsRealChargenOptions_ SelectHeritageIsAccepted proves F6's install actually opens the gate: a content lease carrying a real hand-built DatCharGen heritage (not ChargenOptions.Empty) is installed, and TrySelectHeritage for it succeeds. R2 — filed docs/ISSUES.md #402 for the pre-existing Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate full-suite flake (unrelated to Campaign CC). R4 — fixed "unchached" -> "uncached" typo in InteractionRetainedUiComposition.cs. Runtime 1713/0, App 5127/13 skips (+2), Headless 166/0 (+1), full solution Release build green. Live-DAT probes 7/7 under ACDREAM_PROBE_LIVE_MOUNT=1. The known #402 flake did not fire across 3 consecutive full-suite runs this session. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 30 ++ .../retail-divergence-register.md | 2 +- .../2026-08-15-character-creation-campaign.md | 2 +- .../InteractionRetainedUiComposition.cs | 2 +- .../Layout/CharacterCreationUiController.cs | 43 +- .../Layout/CharacterManagementUiController.cs | 16 +- src/AcDream.App/UI/UiRoot.cs | 86 ++++ ...CharacterScreensFixedCanvasArbiterTests.cs | 430 ++++++++++++++++++ .../HeadlessSessionHostTests.cs | 198 ++++++++ 9 files changed, 780 insertions(+), 29 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index f209a429..c9131e81 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,36 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #402 — Flaky test: Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate + +**Status:** OPEN (flake, not a regression) +**Severity:** LOW (test-infra noise; no known production defect) +**Filed:** 2026-08-15 (Campaign CC slice CC4 review fix round, R2 — noticed +while running the full App.Tests suite repeatedly for the F1/R1 +FixedCanvasSize arbiter gate) +**Component:** `tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs` + +`Build_UsesTheSuppliedSharedReaderGate` fails intermittently in full-suite +runs (observed roughly 2 of 5 runs) but passes reliably when run in +isolation (`--filter FullyQualifiedName~Build_UsesTheSuppliedSharedReaderGate`). +The test was last touched at `82f8d4f8` (2026-07-25, Slice I7's parsed- +collision-graph removal) — unrelated to any Campaign CC/CC4 chargen work, +which never touches streaming/collision code. Symptom pattern (passes +isolated, flakes under full-suite parallelism) points at shared mutable +state or a timing assumption racing another test class rather than the +factory logic itself; not yet root-caused. + +**Fix direction:** re-run the full suite a few times to reproduce and +capture the failure's actual assertion/exception (not just "sometimes +red"), then check `LandblockBuildFactoryTests`'s fixture for anything +shared across test classes (static state, a shared reader/gate instance, +file-system paths) that a parallel xUnit collection could race. + +**Acceptance:** the flake is reproduced with a captured failure detail, +root-caused, and fixed (or the test is isolated into its own collection if +the root cause is unavoidable cross-test parallelism); full-suite runs stop +intermittently failing on this test. + ## #401 — RetailUi should default ON (opt-out), not per-path forced **Status:** OPEN (product-default decision) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 137b8230..a5d5f27f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -189,7 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | -| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | +| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. **Campaign CC CC4 review-fix round R1 (2026-08-15): `FixedCanvasSize` now has a single arbiter.** Character-creation can be simultaneously active on top of character-management (both author the same 800x600 canvas), so a raw property write from either controller was a last-writer-wins race with no owner — chargen's own Close() nulled the canvas out from under a still-active character-management screen underneath it. `UiRoot.DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` now own every production write: each screen declares on its activation edge and revokes on close/deactivate/dispose; the effective size is the current declaration set's value (asserted equal across every concurrent declarer — a future mismatched screen throws instead of silently winning), and it nulls only once EVERY declarer has revoked. The raw `FixedCanvasSize` setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `DeclareFixedCanvas`, `RevokeFixedCanvas`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` and `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (both declare/revoke through the arbiter on activate/close/deactivate/dispose) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) DECLARES via `UiRoot.DeclareFixedCanvas` while active and REVOKES on close — per-screen opt-in through the arbiter, not automatic and not a raw write. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `CharacterScreensFixedCanvasArbiterTests` (the two-controller arbiter gate); `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | 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) | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index d5e955c3..a17cd6e0 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 | 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. | +| 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. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | | CC5 | — | | | | | CC6a | — | | | | | CC6b | — | | | | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index fe0d13f2..84ae9175 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -650,7 +650,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // 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 + // uncached 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 diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index 1c36cef0..ab811feb 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -201,16 +201,15 @@ 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. 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. + // character-management screen. Both controllers DECLARE/REVOKE + // through UiRoot's owner-scoped arbiter (review fix round R1, + // 2026-08-15) rather than writing UiRoot.FixedCanvasSize directly — + // char-management can be simultaneously active underneath this + // screen, and a raw write from either controller is a last-writer- + // wins race with no owner (the F1 fix's own Close() null wiped + // char-management's still-active canvas out from under it). See + // Open/Close/Deactivate/Dispose below for the matching declare/ + // revoke pair. _authoredCanvas = new Vector2( Root.Width > 0f ? Root.Width : 800f, Root.Height > 0f ? Root.Height : 600f); @@ -380,17 +379,19 @@ internal sealed class CharacterCreationUiController : IDisposable /// Opens the screen at retail's authored default page /// (gmCharGenMainUI::gmCharGenMainUI's trailing - /// 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. + /// SetProgressState(this, ECG_HERTAGE)). Declares the fixed + /// canvas on this exact activation edge through 's + /// arbiter — matching 's + /// own one-shot declare — not per-tick; / + /// / revoke it back out + /// symmetrically, and the canvas stays set for as long as ANY other + /// declarer (e.g. character-management underneath) remains active. internal void Open() { if (_disposed) return; _isOpen = true; - _host.FixedCanvasSize = _authoredCanvas; + _host.DeclareFixedCanvas(this, _authoredCanvas); ApplyProgressState(Page.Heritage); } @@ -400,7 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable return; _isOpen = false; Root.Visible = false; - _host.FixedCanvasSize = null; + _host.RevokeFixedCanvas(this); } public void Dispose() @@ -415,9 +416,11 @@ 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; + // unconditional revoke — defends against disposing while + // _isOpen (Close() is not otherwise called on this path). Idle + // if Close() already revoked (RevokeFixedCanvas is a no-op for + // an owner that already revoked). + _host.RevokeFixedCanvas(this); _back.OnClick = null; _next.OnClick = null; _finish.OnClick = null; diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index c9c5b4a3..dc6e9be2 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -114,9 +114,13 @@ internal sealed class CharacterManagementUiController : IDisposable // while this screen is active, the host stretches the ENTIRE canvas — // widgets, glyphs, and the painted background (which carries the // "World"/"Characters" captions as art) — as one unit via - // UiRoot.FixedCanvasSize. Resizing the root here instead is exactly the - // half-substitution that misaligned the widgets against the stretched - // art at the 2026-08-15 user gate. + // UiRoot.FixedCanvasSize, declared/revoked through the owner-scoped + // arbiter (review fix round R1, 2026-08-15) rather than written + // directly — character-creation can be simultaneously active on top + // of this screen, and a raw write from either controller is a last- + // writer-wins race with no owner. Resizing the root here instead of + // using the canvas is exactly the half-substitution that misaligned + // the widgets against the stretched art at the 2026-08-15 user gate. _authoredCanvas = new Vector2( Root.Width > 0f ? Root.Width : 800f, Root.Height > 0f ? Root.Height : 600f); @@ -298,7 +302,7 @@ internal sealed class CharacterManagementUiController : IDisposable { _active = true; Root.Visible = true; - _host.FixedCanvasSize = _authoredCanvas; + _host.DeclareFixedCanvas(this, _authoredCanvas); _host.BringToFront(Root); } @@ -368,7 +372,7 @@ internal sealed class CharacterManagementUiController : IDisposable } finally { - _host.FixedCanvasSize = null; + _host.RevokeFixedCanvas(this); _enter.OnClick = null; _delete.OnClick = null; _restore.OnClick = null; @@ -752,7 +756,7 @@ internal sealed class CharacterManagementUiController : IDisposable { _active = false; Root.Visible = false; - _host.FixedCanvasSize = null; + _host.RevokeFixedCanvas(this); } foreach (UiButton row in _rows) { diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 087053ac..a9374c8a 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Numerics; namespace AcDream.App.UI; @@ -41,9 +42,94 @@ public sealed class UiRoot : UiElement /// renderer's quad chokepoint; the mouse entry points apply the inverse, /// so / and every hit test live /// in canvas space. Null (the in-world default) is native 1:1. + /// + /// + /// Campaign CC slice CC4 review-fix round R1 (2026-08-15): this raw + /// setter remains public for tests that exercise the scale/mouse- + /// mapping math in isolation (UiRootFixedCanvasTests), but + /// PRODUCTION code must go through / + /// instead of writing this property + /// directly. Two fixed-canvas screens can be active at once + /// (character-management underneath character-creation) and a raw + /// write from either one is a last-writer-wins race with no owner — + /// the F1 fix's own Close() null wiped the OTHER screen's still- + /// active canvas out from under it (see AD-98). + /// /// public Vector2? FixedCanvasSize { get; set; } + /// Screens currently declaring a fixed canvas, keyed by owner + /// (see ). + private readonly Dictionary _fixedCanvasDeclarations = new(); + + /// + /// Declares that wants the retained tree laid + /// out in while it is active. This is the single + /// arbiter for : multiple owners may declare + /// concurrently (character-management stays declared while character- + /// creation is also open on top of it), and the effective + /// is the shared declaration set's value. + /// Every current declarer must agree on the size — a mismatched second + /// declaration throws rather than silently overwriting the first + /// (Campaign CC CC4 review-fix round R1, 2026-08-15; see + /// docs/architecture/retail-divergence-register.md AD-98). Pair + /// every call with on the SAME owner at + /// deactivate/close/dispose. + /// + public void DeclareFixedCanvas(object owner, Vector2 size) + { + ArgumentNullException.ThrowIfNull(owner); + if (_fixedCanvasDeclarations.TryGetValue(owner, out Vector2 existing)) + { + if (existing == size) + return; // idempotent re-declare (e.g. a re-ticked activation edge) + throw new InvalidOperationException( + $"UiRoot.DeclareFixedCanvas: owner {owner} re-declared a different " + + $"canvas ({existing} -> {size}) without revoking first."); + } + + foreach (Vector2 declared in _fixedCanvasDeclarations.Values) + { + if (declared != size) + { + throw new InvalidOperationException( + $"UiRoot.DeclareFixedCanvas: owner {owner} declared {size} but " + + $"another active owner already declared {declared} — every " + + "concurrently-active fixed-canvas screen must author the SAME " + + "canvas size (see AD-98)."); + } + } + + _fixedCanvasDeclarations[owner] = size; + FixedCanvasSize = size; + } + + /// Revokes 's declaration from + /// . + /// becomes null only once EVERY declarer has revoked; while another + /// owner is still declared, it stays set to that shared value. A + /// revoke from an owner that never declared (or already revoked) is a + /// no-op, matching the idempotent shutdown paths (Deactivate + /// AND Dispose can both revoke the same owner). + public void RevokeFixedCanvas(object owner) + { + ArgumentNullException.ThrowIfNull(owner); + if (!_fixedCanvasDeclarations.Remove(owner)) + return; + + if (_fixedCanvasDeclarations.Count == 0) + { + FixedCanvasSize = null; + return; + } + + foreach (Vector2 declared in _fixedCanvasDeclarations.Values) + { + FixedCanvasSize = declared; + break; + } + } + /// /// The coordinate space the retained tree currently lays out in: the fixed /// authored canvas while one is active, else the window itself. Anything diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs new file mode 100644 index 00000000..4e1480fc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs @@ -0,0 +1,430 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC slice CC4 review-fix round R1 (2026-08-15): +/// and +/// can be SIMULTANEOUSLY +/// active against the SAME — character-creation opens +/// on top of character-management, which keeps ticking underneath it. Both +/// controllers share , and before this +/// fix each wrote it directly — a raw write from either screen was a last- +/// writer-wins race with no owner: the F1 fix's own chargen +/// Close() nulled the canvas out from under a STILL-ACTIVE +/// character-management screen underneath it (the exact AD-98 gate-round-2 +/// defect resurfacing one layer up). These tests drive +/// / +/// across BOTH controllers on one shared host, per the reviewer's required +/// sequence. +/// +public sealed class CharacterScreensFixedCanvasArbiterTests +{ + private static readonly Vector2 AuthoredCanvas = new(800f, 600f); + + [Fact] + public void CanvasStaysSetWhileEitherScreenIsActive_AndNullsOnlyWhenBothRevoke() + { + using var environment = new TwoControllerHarness(); + + // char-mgmt alone: declared on its own activation edge (Tick's + // `if (!_active)` arm). + Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); + + // chargen opens ON TOP of the still-active char-mgmt screen. + environment.Chargen.Controller.Open(); + Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); + + // chargen Exit-confirms and closes -- char-mgmt is STILL ACTIVE, so + // the canvas must stay set. This is R1's regression: the pre-fix + // Close() nulled UiRoot.FixedCanvasSize unconditionally here, + // stripping it from char-management underneath. + environment.Chargen.Button(CharacterCreationUiController.ExitElementId) + .OnClick!(); + environment.Chargen.ConfirmActiveDialog(confirmed: true); + Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); + Assert.False(environment.Chargen.Controller.Root.Visible); + + // char-mgmt deactivates (e.g. entering the world) -- now NEITHER + // screen declares, so the canvas nulls. + environment.Management.Runtime.SetLifecycle( + RuntimeCharacterSelectionLifecycle.InWorld); + environment.Management.Controller.Tick(); + Assert.Null(environment.Host.FixedCanvasSize); + } + + /// The original F1 defect's own covering case: both screens + /// revoke together (world entry while chargen was ALSO still open) + /// still nulls the canvas -- not just "one revokes while the other + /// holds," the scenario above. + [Fact] + public void CanvasNulls_WhenBothScreensRevokeAtWorldEntry() + { + using var environment = new TwoControllerHarness(); + environment.Chargen.Controller.Open(); + Assert.Equal(AuthoredCanvas, environment.Host.FixedCanvasSize); + + environment.Management.Runtime.SetLifecycle( + RuntimeCharacterSelectionLifecycle.InWorld); + environment.Management.Controller.Tick(); + environment.Chargen.Runtime.ProvideView = false; + environment.Chargen.Controller.Tick(); + + Assert.Null(environment.Host.FixedCanvasSize); + } + + // ── Fixture: one shared UiRoot, both controllers ──────────────────── + + private sealed class TwoControllerHarness : IDisposable + { + public TwoControllerHarness() + { + Host = new UiRoot { Width = 800f, Height = 600f }; + Management = new ManagementHarness(Host); + Chargen = new ChargenHarness(Host); + } + + public UiRoot Host { get; } + public ManagementHarness Management { get; } + public ChargenHarness Chargen { get; } + + public void Dispose() + { + Chargen.Dispose(); + Management.Dispose(); + } + } + + private sealed class ManagementHarness : IDisposable + { + private readonly RetailDialogFactory _dialogs; + + public ManagementHarness(UiRoot host) + { + ImportedLayout screen = BuildManagementScreen(); + Runtime = new ManagementFakeRuntime(); + _dialogs = new RetailDialogFactory( + host, + type => RetailDialogFactoryTests.BuildDialogLayout(type)); + Controller = Assert.IsType( + CharacterManagementUiController.Bind( + host, + screen, + static (_, _) => BuildRow(), + _dialogs, + Runtime.Bindings, + new CharacterManagementUiController.DialogStrings( + name => $"WARNING! {name}", + "DELETE", + "Please Wait", + "Entering World", + "Are you sure you want to leave?"))); + } + + public ManagementFakeRuntime Runtime { get; } + public CharacterManagementUiController Controller { get; } + + public void Dispose() + { + Controller.Dispose(); + _dialogs.Dispose(); + } + + private static UiElement BuildRow() => LayoutImporter.Build( + new ElementInfo { Id = 0x100003A5u, Type = 1u, Width = 160f, Height = 16f }, + _ => (0u, 0, 0), + null).Root; + + private static ImportedLayout BuildManagementScreen() + { + var root = new ElementInfo + { + Id = CharacterManagementUiController.RootElementId, + Type = 3u, + Width = 800f, + Height = 600f, + }; + var list = new ElementInfo + { + Id = CharacterManagementUiController.ListElementId, + Type = 5u, + X = 42f, + Y = 212f, + Width = 160f, + Height = 320f, + }; + list.TemplateList.Add(new UiTemplateListEntry(0x21000004u, 0x100003A5u)); + root.Children.Add(list); + root.Children.Add(new ElementInfo + { + Id = CharacterManagementUiController.WorldTextElementId, + Type = 12u, + Width = 193f, + Height = 110f, + }); + root.Children.Add(ButtonInfo(CharacterManagementUiController.CreateElementId)); + root.Children.Add(ButtonInfo(CharacterManagementUiController.EnterElementId)); + root.Children.Add(ButtonInfo(CharacterManagementUiController.DeleteElementId)); + root.Children.Add(ButtonInfo(CharacterManagementUiController.RestoreElementId)); + root.Children.Add(ButtonInfo(CharacterManagementUiController.CreditsElementId)); + root.Children.Add(ButtonInfo(CharacterManagementUiController.ExitElementId)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + } + + private sealed class ManagementFakeRuntime + { + private static readonly RuntimeGenerationToken Generation = new(11u); + private readonly FakeManagementView _view = new(); + + public ManagementFakeRuntime() + { + _view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)]; + _view.Snapshot = new RuntimeCharacterSelectionSnapshot( + Generation, + RuntimeCharacterSelectionLifecycle.AwaitingSelection, + Revision: 1, + AccountName: "account", + SlotCount: 5, + RosterCount: _view.Entries.Length, + WorldName: "sawato", + HighlightedCharacterId: 0x50000001u, + HighlightedDisplayIndex: 0, + PendingDeleteCharacterId: 0u, + LastRestoreRequestedCharacterId: 0u, + Operation: RuntimeCharacterSelectionOperation.None, + Error: null, + Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false)); + Bindings = new CharacterSelectionRuntimeBindings( + View: () => _view, + Highlight: _ => Result(), + Enter: Result, + RequestDelete: Result, + ConfirmDelete: Result, + Restore: Result, + Cancel: Result, + RequestExit: () => { }); + } + + public CharacterSelectionRuntimeBindings Bindings { get; } + + private static RuntimeCommandResult Result() => + new(RuntimeCommandStatus.Accepted, Generation); + + public void SetLifecycle(RuntimeCharacterSelectionLifecycle lifecycle) + { + RuntimeCharacterSelectionSnapshot current = _view.Snapshot; + _view.Snapshot = current with { Lifecycle = lifecycle, Revision = current.Revision + 1 }; + } + + private sealed class FakeManagementView : IRuntimeCharacterSelectionView + { + public RuntimeCharacterSelectionEntry[] Entries { get; set; } = []; + public RuntimeCharacterSelectionSnapshot Snapshot { get; set; } + + public bool TryGetAt(int displayIndex, out RuntimeCharacterSelectionEntry character) + { + if ((uint)displayIndex >= (uint)Entries.Length) + { + character = default; + return false; + } + character = Entries[displayIndex]; + return true; + } + + public bool TryGet(uint characterId, out RuntimeCharacterSelectionEntry character) + { + int index = Array.FindIndex(Entries, entry => entry.CharacterId == characterId); + if (index < 0) + { + character = default; + return false; + } + character = Entries[index]; + return true; + } + + public void Visit(IRuntimeCharacterSelectionVisitor visitor) + { + foreach (RuntimeCharacterSelectionEntry character in Entries) + visitor.Visit(in character); + } + + public IDisposable Subscribe(IRuntimeCharacterSelectionObserver observer) => + NullSubscription.Instance; + + private sealed class NullSubscription : IDisposable + { + public static readonly NullSubscription Instance = new(); + public void Dispose() { } + } + } + } + + private sealed class ChargenHarness : IDisposable + { + private readonly List _dialogLayouts = []; + private readonly RetailDialogFactory _dialogs; + + public ChargenHarness(UiRoot host) + { + Screen = BuildChargenScreen(); + Runtime = new ChargenFakeRuntime(); + _dialogs = new RetailDialogFactory(host, type => + { + ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type); + _dialogLayouts.Add(layout); + return layout; + }); + Controller = Assert.IsType( + CharacterCreationUiController.CreateDetached( + host, + Screen, + static (_, _) => null, + _dialogs, + Runtime.Bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure you want to leave?"))); + Controller.AttachAndTick(); + } + + public ImportedLayout Screen { get; } + public ChargenFakeRuntime Runtime { get; } + public CharacterCreationUiController Controller { get; } + + public UiButton Button(uint id) => + Assert.IsType(Screen.FindElement(id)); + + public void ConfirmActiveDialog(bool confirmed) + { + ImportedLayout dialog = _dialogLayouts[^1]; + uint buttonId = confirmed + ? RetailConfirmationDialogView.AcceptButtonId + : RetailConfirmationDialogView.RejectButtonId; + UiButton button = Assert.IsType(dialog.FindElement(buttonId)); + button.OnClick!(); + } + + public void Dispose() + { + Controller.Dispose(); + _dialogs.Dispose(); + } + + private static ImportedLayout BuildChargenScreen() + { + var root = new ElementInfo + { + Id = CharacterCreationUiController.RootElementId, + Type = 3u, + Width = 800f, + Height = 600f, + }; + root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.HeritagePageElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.ProfessionPageElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.SkillsPageElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.TownPageElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + + private static ElementInfo ContainerInfo(uint id) => + new() { Id = id, Type = 3u, Width = 200f, Height = 60f }; + } + + private sealed class ChargenFakeRuntime + { + private static readonly RuntimeGenerationToken Generation = new(13u); + + public ChargenFakeRuntime() + { + View = new FakeChargenView(); + Bindings = new CharacterCreationRuntimeBindings( + () => ProvideView ? View : null, + _ => Result(), + _ => Result(), + _ => Result(), + (_, _) => Result(), + (_, _) => Result(), + _ => Result(), + _ => Result(), + _ => Result(), + _ => Result(), + _ => Result(), + RequestExit: () => { }, + ResolveText: _ => null, + OpenOnStart: false); + } + + public FakeChargenView View { get; } + public CharacterCreationRuntimeBindings Bindings { get; } + public bool ProvideView { get; set; } = true; + + private static RuntimeCommandResult Result() => + new(RuntimeCommandStatus.Accepted, Generation); + + public sealed class FakeChargenView : IRuntimeCharacterCreationView + { + public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = + new( + Generation, + IsActive: true, + Revision: 1, + HeritageId: 0u, + GenderKey: 0u, + Appearance: RuntimeCharacterCreationAppearance.Default, + Template: RuntimeCharacterCreationSnapshot.TemplateUnset, + Attributes: default, + AttributeLockMask: 0u, + TotalAttributeCredits: 0u, + RemainingAttributeCredits: 0, + TotalSkillCredits: 0u, + RemainingSkillCredits: 0, + Name: string.Empty, + StartArea: -1, + Slot: 0u, + VerificationPending: false, + LastLocalRefusal: default, + LastRejection: null, + LastCreated: null); + + public ChargenOptions Options => ChargenOptions.Empty; + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + ChargenSkillAdvancementClass.Inactive; + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => + NullSubscription.Instance; + + private sealed class NullSubscription : IDisposable + { + public static readonly NullSubscription Instance = new(); + public void Dispose() { } + } + } + } + + private static ElementInfo ButtonInfo(uint id) => + new() { Id = id, Type = 1u, Width = 100f, Height = 30f }; +} diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 7a7081ac..45bea702 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -1,10 +1,15 @@ using System.Buffers.Binary; using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Numerics; using System.Reflection; +using System.Runtime.InteropServices; using System.Text.Json; +using AcDream.Content; +using AcDream.Content.CharGen; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -19,11 +24,68 @@ using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; using AcDream.Runtime.World; +using DatCharGen = DatReaderWriter.DBObjs.CharGen; +using DatHeritageGroupCG = DatReaderWriter.Types.HeritageGroupCG; +using DatIDBObj = DatReaderWriter.Lib.IO.IDBObj; +using DatDatabaseImpl = DatReaderWriter.DatDatabase; +using DatPStringBaseByte = DatReaderWriter.Types.PStringBase; namespace AcDream.Headless.Tests; public sealed class HeadlessSessionHostTests { + /// + /// Review fix round R3 (2026-08-15): proves the F6 fix actually opens + /// the gate, not just that InstallOptions was called. A content + /// lease carrying a REAL chargen table (one heritage that does NOT + /// exist in ) is + /// installed by the host constructor; selecting that heritage — refused + /// against the pre-F6 empty default — is ACCEPTED once the session + /// reaches character-creation-active. Begin/TrySelectHeritage + /// are called directly (both internal, reachable via this + /// project's InternalsVisibleTo on AcDream.Runtime) to isolate the + /// F6 wiring from the unrelated real-network handshake that would + /// otherwise be needed to reach the same session state. + /// + [Fact] + public void ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted() + { + var factory = new ChargenFixtureContentFactory(); + using var owner = new HeadlessProcessContentOwner( + ContentDescriptor(), + _ => { }, + factory); + HeadlessProcessContentOwner.HeadlessProcessContentLease lease = + owner.AcquireLease("chargen-fixture"); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var diagnosticsOutput = new StringWriter(); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + new FixtureSessionOperations(), + contentLease: lease); + + RuntimeCharacterCreationState creation = + host.Runtime.Session.CharacterCreationState; + + // F6 proof #1: the fixture heritage (absent from ChargenOptions.Empty) + // is actually installed off the content lease's real Dats. + Assert.True(creation.Options.HeritagesById.ContainsKey( + ChargenFixtureContentFactory.HeritageId)); + + // F6 proof #2: with real options installed, selecting that heritage + // is accepted once character-creation is active (Begin mirrors the + // real wire trigger this unit test bypasses). + creation.Begin(host.Runtime.Generation); + Assert.True(creation.TrySelectHeritage(ChargenFixtureContentFactory.HeritageId)); + Assert.Equal( + ChargenFixtureContentFactory.HeritageId, + creation.View.Snapshot.HeritageId); + } + [Fact] public void LoginCommandsUseTheHeadlessLiveBusAndPreserveWireOrder() { @@ -3685,4 +3747,140 @@ public sealed class HeadlessSessionHostTests } } + private static HeadlessContentDescriptor ContentDescriptor() => new() + { + DatDirectory = "fixture-dats", + PreparedAssetPath = "fixture.pak", + }; + + /// Review fix round R3 (2026-08-15): a content factory whose + /// Dats is a REAL (hand-built, not DispatchProxy-stubbed) + /// serving one heritage off + /// — the existing + /// TestResourceProxy pattern elsewhere in this project always + /// returns null/default and can't serve typed record data. + private sealed class ChargenFixtureContentFactory : IHeadlessProcessContentFactory + { + internal const uint HeritageId = 1u; + + public HeadlessOpenedProcessContent Open( + HeadlessContentDescriptor descriptor, + Action diagnostic) => + new( + new FixtureChargenDatReaderWriter(), + DispatchProxy.Create(), + MagicCatalog.Empty, + ImmutableArray.CreateRange(new float[256])); + } + + /// Minimal mirroring + /// ChargenTableReaderTests.EmptyDatReaderWriter's shape, except + /// serves a real for + /// — everything else + /// still misses, matching the missing-table tolerance + /// already has tests for. + private sealed class FixtureChargenDatReaderWriter : IDatReaderWriter + { + private readonly StubDatabase _db = new(); + private readonly DatCharGen _chargenTable = BuildChargenTable(); + + public string SourceDirectory => string.Empty; + public IDatDatabase Portal => _db; + public IDatDatabase Cell => _db; + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => _db; + public IDatDatabase Language => _db; + public IDatDatabase Local => _db; + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes( + uint regionId, + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : DatIDBObj => + Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : DatIDBObj => + throw new NotSupportedException(); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : DatIDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : DatIDBObj => + fileId == ChargenTableReader.ChargenTableDid + && typeof(T) == typeof(DatCharGen) + ? (T)(object)_chargenTable + : default; + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) + where T : DatIDBObj + { + value = Get(fileId); + return value is not null; + } + + public void Dispose() { } + + private static DatCharGen BuildChargenTable() + { + var table = new DatCharGen(); + var heritage = new DatHeritageGroupCG + { + Name = Str("FixtureHeritage"), + AttributeCredits = 60u, + SkillCredits = 50u, + }; + table.HeritageGroups.Add(ChargenFixtureContentFactory.HeritageId, heritage); + return table; + } + + private static DatPStringBaseByte Str(string value) + { + var s = new DatPStringBaseByte(); + s.Value = value; + return s; + } + + private sealed class StubDatabase : IDatDatabase + { + public DatDatabaseImpl Db => null!; + public int Iteration => 0; + public IEnumerable GetAllIdsOfType() where T : DatIDBObj => + Array.Empty(); + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) + where T : DatIDBObj + { + value = default; + return false; + } + public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) + { + value = default; + return false; + } + public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) + { + bytesRead = 0; + return false; + } + public bool TrySave(T obj, int iteration = 0) where T : DatIDBObj => false; + public void Dispose() { } + } + } } From 8dfee1118f5be0a760a60e30a180942f194d5b58 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:05:56 +0200 Subject: [PATCH 092/138] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice?= =?UTF-8?q?=20CC6b-PRE=20=E2=80=94=20idle=20loop,=20rotation,=20zoom=20(mo?= =?UTF-8?q?unt-independent=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set away from its zero default, unlike its two sibling bools) establishes that retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose only appears once Zoom In fires. New Core primitive RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's advance-with-wrap + lerp/slerp effect (the same algorithm LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline; not consolidated this round — out of blast radius for a preview-only feature, noted in the new type's own doc). New ChargenPreviewAnimator drives the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired in the register (§4 count 50->49). Rotation controller: ChargenPreviewRotationController ports Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop, deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass +-360 clamp (not a full modulo, matching retail's own tail), the -1.0 invalidation sentinel. Applies to the entity's heading via the existing MoveToMath.SetHeading port, not the camera, confirming CC6a's own note. Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/ DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween (no easing curve in the decomp) between the already-recorded camera eye profiles, calling into the animator's zoom swap IMMEDIATELY at button-press time, matching retail's call order exactly. m_alternateSetupID (research correction): re-reading the decomp function-by-function found all five m_alternateSetupID write sites — including the two the CC6a review cited — belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no m_pOption1Checkbox-equivalent field and never writes the field. For character creation the field is always INVALID_DID in retail. TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default no-op) implementing gmCG3DView::Update's generic override precedence, for a future non-chargen consumer. RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a clean mechanical extraction, behavior-identical on the paperdoll side. Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca, 1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half still owed. Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests (7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests (+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests 5149/6 skips — zero failures, full solution Release build green. One pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss soak failed once in the full-suite run, passed 1/1 isolated. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../2026-08-15-character-creation-campaign.md | 4 +- .../Rendering/ChargenPreviewAnimator.cs | 135 ++++++++++ .../Rendering/ChargenPreviewCamera.cs | 13 +- .../Rendering/ChargenPreviewEntityBuilder.cs | 233 ++++++++++++++---- .../Rendering/ChargenPreviewRenderer.cs | 36 ++- .../ChargenPreviewRotationController.cs | 114 +++++++++ .../Rendering/ChargenPreviewZoomController.cs | 135 ++++++++++ .../Rendering/PaperdollFramePresenter.cs | 30 +-- src/AcDream.App/Rendering/RetailHeldPose.cs | 61 +++++ .../CharGen/ChargenAppearanceFactory.cs | 57 ++++- .../Physics/RetailAnimationCyclePlayback.cs | 123 +++++++++ .../Rendering/ChargenPreviewAnimatorTests.cs | 154 ++++++++++++ .../ChargenPreviewEntityBuilderTests.cs | 151 ++++++++++++ .../ChargenPreviewRotationControllerTests.cs | 117 +++++++++ .../ChargenPreviewZoomControllerTests.cs | 166 +++++++++++++ .../CharGen/ChargenAppearanceFactoryTests.cs | 80 ++++++ .../RetailAnimationCyclePlaybackTests.cs | 153 ++++++++++++ 18 files changed, 1657 insertions(+), 108 deletions(-) create mode 100644 src/AcDream.App/Rendering/ChargenPreviewAnimator.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewRotationController.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewZoomController.cs create mode 100644 src/AcDream.App/Rendering/RetailHeldPose.cs create mode 100644 src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1f0370f4..30946327 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -389,11 +389,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 50 active rows (TS-83 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview holds a static rest-pose final frame instead of retail's live 30fps idle loop, explicitly staged for CC6b to retire; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — which the ctor never explicitly sets away from its zero-initialized default — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-83 | Chargen 3D preview (Campaign CC slice CC6a foundation): the preview holds a STATIC final-frame rest pose (`ChargenPreviewEntityBuilder.ApplyHeldPose`, retail's `m_didAnimationRest` DID resolution) instead of retail's live 30fps idle loop (`gmCG3DView`'s `m_didAnimation`/`m_didAnimArray` family, driven via `set_sequence_animation`). Deliberately staged, not discovered late: the campaign plan's own CC6 slice row names this exact split ("CC6a static-pose preview... register row for the missing idle loop, CC6b idle animation... retire the row"). | `src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs` (`ApplyHeldPose`); `src/AcDream.App/Rendering/ChargenPreviewRenderer.cs` | Explicitly staged per `docs/plans/2026-08-15-character-creation-campaign.md`'s CC6 slice split; the identical held-pose technique is the paperdoll's own PERMANENT (not staged) design (`RetailPaperdollPoseApplicator.Apply`), so the mechanism itself is proven, only the "hold forever vs. play then hold" choice is temporary here. | The chargen preview shows a motionless character instead of retail's idle sway/breathing loop — cosmetic only; does not affect the composed appearance data (setup id, palette, part/texture overrides) CC6b's page will bind to. | `gmCG3DView` ctor + `::Update @ 0x004EE9D0` (`m_didAnimation`/`m_didAnimArray`/`m_didAnimationRest` DID assignments, pseudo-C ~0x004EE7C6-0x004EE995); `CreatureMode::set_sequence_animation` (idle-loop playback entry point, not yet located precisely — CC6b to find) | | TS-82 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index bea19fab..e3ddc703 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -253,8 +253,8 @@ the user gate. | 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 | — | | | | | CC5 | — | | | | -| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | single commit, HEAD of `campaign-cc6a` (plus a same-session review fix-round commit, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-82's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-82 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | -| CC6b | NOT STARTED | | | **MUST-COVER, carried from the CC6a review fix round (F11/F12):** (1) retail's SECOND Setup-override source — `gmCG3DView`'s `m_alternateSetupID`, set from the Appearance page's option checkbox (Penumbraen crown variant `@0x004DFB3F`, Undead no-flame variant `@0x004E0C54`), takes precedence over the hair style's `AlternateSetup` at `gmCG3DView::Update`'s own resolution (`@0x004EEA51`) — CC6a's factory only ports the hair-style source; this second source is completely unmodelled and needs its own citation-backed port + register-row bookkeeping if CC6b doesn't fully close it. (2) Before adding a FOURTH consumer of the "resolve a rest-pose DID via master-map slot 7, load its Animation, hold the final frame" algorithm (paperdoll's `RetailPaperdollPoseApplicator`, CC6a's `ChargenPreviewEntityBuilder.ApplyHeldPose`/`ResolvePoseDid` are the second and third), extract a shared `RetailHeldPose` helper rather than copying it a third time. | +| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | single commit, HEAD of `campaign-cc6a` | Review outstanding (dual-lens Opus pass not yet run this round) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the ctor evidence that `m_bZoomedIn` is one of three consecutive bool bytes the decompiler shows only two of (`m_bShouldZoomAnimate`/`m_bRotating` explicitly zeroed, `m_bZoomedIn` never explicitly touched — the same decompiler-elision class `claude-memory/feedback_bn_decomp_field_names.md` warns about) settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept byte-behavior-identical — a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI::ListenToElementMessage`/`::InitializePage` (confirmed via the enclosing-function scan: `gmBarberUI::SetSelection`/`::Rotate` calls and a `CM_Character::Event_FinishBarber` wire call sit in the SAME function bodies), the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`, which has NO `m_pOption1Checkbox`-equivalent field anywhere in its own field list (`acclient.h:56373-56428`, checked exhaustively) and never writes `m_alternateSetupID` in any of its own methods. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, this campaign's own scope. `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism for a future non-chargen consumer of this same factory, not a fabricated feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (9), `ChargenPreviewZoomControllerTests` (7), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (+14 from the CC6a baseline of 4772/1), Content.Tests 147/0 skips (unchanged — no Content-layer work this round), App.Tests 5149/6 skips (+28 from 5121/6) — zero failures, full solution Release build green. One PRE-EXISTING flake noted, not caused by this round: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` failed once in the full-suite run, passed 1/1 in isolation — a randomized-loss-injection timing flake in the unrelated Core.Net transport suite (zero files under `src/AcDream.Core.Net/` touched this round). **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to the three new controllers' `Tick`/`Toggle`/`ZoomIn`/`ZoomOut` methods, spin controls, color wheels. | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs new file mode 100644 index 00000000..51f144d3 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Owns the chargen preview's per-frame idle-loop ↔ rest-pose playback, +/// mirroring gmCG3DView::StartAnimation/StopAnimation's swap +/// (0x004EE600/0x004EE640) and +/// gmCGAppearancePage::ZoomIn/ZoomOut's immediate call into it +/// (0x0047D024/0x0047D160 — the swap happens the instant the +/// button is pressed, NOT once the camera's own 0.6s tween finishes). +/// +/// +/// Retail default is idle-PLAYING, not frozen — see +/// 's class doc for the decomp +/// citations. This class's own default ( starts +/// false) reproduces that: its constructor immediately plays the +/// idle animation's frame 0 when one resolved, matching +/// gmCGAppearancePage::Update's own trailing +/// if (m_bZoomedIn == 0) StartAnimation() gate +/// (~0x0047EF01-0x0047EF12), which re-fires on every heritage/gender/ +/// appearance change too — restarts the idle loop +/// at frame 0 on every transition INTO the playing state for the same +/// reason: set_sequence_animation's arg3=1 clears the sequence +/// before appending, so every StartAnimation call restarts the clip. +/// +/// +/// +/// The page-mount half (CC6b, after CC4 merges) wires the Zoom In/Out +/// buttons to and the render loop to +/// ; nothing in this repository calls either yet. +/// +/// +internal sealed class ChargenPreviewAnimator +{ + /// + /// gmCG3DView::StartAnimation's literal framerate argument + /// (set_sequence_animation(this->m_pPlayerObject, + /// this->m_didAnimation.id, 1, 0, 30f), pseudo-C ~0x004ee61b). + /// + public const float IdleFramerate = 30f; + + private readonly ChargenPreviewAnimatedBuild _build; + private float _currFrame; + private bool _zoomedIn; + + public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build) + { + _build = build ?? throw new ArgumentNullException(nameof(build)); + _currFrame = build.IdleLowFrame; + if (build.IdleAnimation is not null) + ApplyIdleFrame(); // retail's true default: idle playing, frame 0. + // Else: Entity.MeshRefs already holds RestMeshRefs (set by + // TryBuildAnimated) as the best available fallback. + } + + /// The live preview entity — mutated in place by + /// and ; the renderer never needs to re-call + /// SetPreview after the first assignment (WorldEntity.MeshRefs + /// is read fresh every draw — see its own doc comment). + public WorldEntity Entity => _build.Entity; + + public bool IsZoomedIn => _zoomedIn; + + /// + /// gmCGAppearancePage::ZoomIn/ZoomOut's + /// StopAnimation/StartAnimation call, applied immediately + /// (retail does not wait for the camera tween to finish before swapping + /// animation state — see this class's own doc comment). No-op if + /// already in the requested state, matching retail's own early-return + /// guards (ZoomIn's if (m_bZoomedIn != 0) return, + /// ZoomOut's mirror). + /// + public void SetZoomedIn(bool zoomedIn) + { + if (_zoomedIn == zoomedIn) + return; + _zoomedIn = zoomedIn; + if (zoomedIn) + { + _build.Entity.MeshRefs = _build.RestMeshRefs; + } + else + { + _currFrame = _build.IdleLowFrame; + if (_build.IdleAnimation is not null) + ApplyIdleFrame(); + } + } + + /// + /// Advances the idle loop by . No-op + /// while zoomed in (the rest pose is frozen — retail's framerate-0 + /// set_sequence_animation call never advances) or when no idle + /// Animation resolved (heritage/DID gap; the entity keeps whatever pose + /// the constructor seeded). + /// + public void Tick(float elapsedSeconds) + { + if (_zoomedIn || _build.IdleAnimation is null || elapsedSeconds <= 0f) + return; + + _currFrame = RetailAnimationCyclePlayback.Advance( + _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, IdleFramerate, elapsedSeconds); + ApplyIdleFrame(); + } + + private void ApplyIdleFrame() + { + DatReaderWriter.DBObjs.Animation animation = _build.IdleAnimation!; + IReadOnlyList parts = _build.DrawableParts; + var meshRefs = new List(parts.Count); + foreach (ChargenPreviewDrawablePart part in parts) + { + bool resolved = RetailAnimationCyclePlayback.TryInterpolatePart( + animation, _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, + part.SetupPartIndex, out Vector3 origin, out Quaternion orientation); + // Same defensive default as ApplyHeldPoseTransforms: a part + // index the bracketing frame doesn't cover (a Setup/Animation + // part-count mismatch, never expected in practice) keeps + // identity rather than a degenerate zero quaternion. + if (!resolved) + { + origin = Vector3.Zero; + orientation = Quaternion.Identity; + } + Matrix4x4 transform = RetailHeldPose.ComposePartTransform(part.DefaultScale, origin, orientation); + meshRefs.Add(new MeshRef(part.GfxObjId, transform) { SurfaceOverrides = part.SurfaceOverrides }); + } + _build.Entity.MeshRefs = meshRefs; + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs index 138dab5f..98db610d 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -25,10 +25,15 @@ namespace AcDream.App.Rendering; /// button (gmCGAppearancePage::DoRotation @ 0x0047CA80) advances a /// HEADING applied to the preview CHARACTER (CPhysicsObj::set_heading /// inside gmCG3DView::Update, pseudo-C ~242088) — the camera's own -/// position/direction never change during a rotation. CC6b's heading -/// parameter therefore belongs on the entity builder -/// (), not here; this class stays a -/// fixed-per-heritage eye, exactly like retail's own camera. +/// position/direction never change during a rotation. The heading itself +/// lives on (CC6b: the +/// DoRotation/Rotate port) and is applied to the entity via +/// ChargenPreviewEntityBuilder.TryBuild/TryBuildAnimated's +/// heading parameter, not here; this class stays a fixed-per-heritage +/// eye, exactly like retail's own camera. +/// (CC6b: the ZoomIn/ZoomOut/DoZoomAnimation port) DOES +/// mutate this class's — zoom is a camera concern, unlike +/// rotation. /// /// public sealed class ChargenPreviewCamera : ICamera diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs index 0717430a..bc040a1d 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -10,7 +10,50 @@ using DatReaderWriter.DBObjs; namespace AcDream.App.Rendering; /// -/// Builds the static-pose chargen preview from a +/// One resolved drawable part of the chargen preview body — a Setup part +/// index (needed to sample Animation.PartFrames[frame].Frames[index] +/// and Setup.DefaultScale[index]) paired with its resolved GfxObj id, +/// default scale (captured once at build time — scale never changes across +/// an idle cycle), and surface overrides. +/// walks this list every tick without touching the dat source again. +/// +internal readonly record struct ChargenPreviewDrawablePart( + int SetupPartIndex, + uint GfxObjId, + Vector3 DefaultScale, + IReadOnlyDictionary? SurfaceOverrides); + +/// +/// The richer sibling of 's +/// result: the built (seeded with retail's true +/// default pose — see ) plus everything +/// needed to drive it frame-by-frame without re-touching the dat source — +/// the resolved drawable parts, the precomputed frozen rest pose, and the +/// resolved idle Animation + its frame range. +/// +internal sealed class ChargenPreviewAnimatedBuild +{ + public required WorldEntity Entity { get; init; } + public required IReadOnlyList DrawableParts { get; init; } + + /// + /// The held final-frame rest pose, precomputed once (retail: + /// gmCG3DView::StopAnimation's framerate-0 + /// set_sequence_animation call never advances, so there is + /// nothing to recompute per tick while zoomed in). Falls back to each + /// part's raw Setup-default transform (no-op) when the rest DID doesn't + /// resolve, matching the pre-CC6b ApplyHeldPose no-op behavior. + /// + public required IReadOnlyList RestMeshRefs { get; init; } + + /// Retail's live idle DID (m_didAnimation), or null if unresolved. + public Animation? IdleAnimation { get; init; } + public int IdleLowFrame { get; init; } + public int IdleHighFrame { get; init; } +} + +/// +/// Builds the chargen preview from a /// — the App-layer counterpart to /// , except this one resolves its OWN /// MeshRefs from a Setup + the composed ObjDesc rather than receiving @@ -20,7 +63,30 @@ namespace AcDream.App.Rendering; /// closest existing precedent for the actual mesh-flatten/apply-changes/ /// resolve-surface-overrides steps is /// DatLiveEntityProjectionMaterializer.TryMaterialize, trimmed to -/// what a private, non-animated, non-collision preview scene needs. +/// what a private, non-collision preview scene needs. +/// +/// +/// CC6b: retail's chargen preview does NOT default to a frozen pose — +/// gmCGAppearancePage::Update's own trailing gate +/// (~0x0047EF01-0x0047EF12) calls gmCG3DView::StartAnimation (idle +/// loop playing) whenever m_bZoomedIn == 0, and that field is never +/// explicitly initialized away from its zero-initialized default in the +/// ctor (gmCGAppearancePage::gmCGAppearancePage, pseudo-C +/// ~0x0047CD58-0x0047CD64 — m_bShouldZoomAnimate/m_bRotating/ +/// m_bZoomedIn are three consecutive bool bytes the decompiler shows +/// only the first two of, a known decompiler-elision class per +/// claude-memory/feedback_bn_decomp_field_names.md). So retail's +/// chargen preview plays its idle loop (m_didAnimation, 30fps) from +/// the very first frame; the REST pose (m_didAnimationRest, held +/// final frame, this class's pre-CC6b-only behavior) only appears once the +/// user presses Zoom In (gmCGAppearancePage::ZoomIn calls +/// gmCG3DView::StopAnimation immediately, before its camera tween +/// even starts). keeps its ORIGINAL (rest-only) +/// behavior unchanged for its existing callers; +/// plus are the new, retail-accurate +/// entry point a live preview (idle-playing by default, freezing on zoom-in) +/// should use. +/// /// internal static class ChargenPreviewEntityBuilder { @@ -37,8 +103,8 @@ internal static class ChargenPreviewEntityBuilder public const uint PreviewRenderId = 0xDA11_D032u; /// - /// Retail's held-pose animation DID enum key, resolved through master - /// map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid + /// Retail's held-pose (REST) animation DID enum key, resolved through + /// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid /// — 0x10000005 for every standard heritage (the SAME enum id the /// paperdoll's own held pose reads), matching /// gmCG3DView's ctor / ::Update per-heritage @@ -55,10 +121,35 @@ internal static class ChargenPreviewEntityBuilder }; /// - /// Builds the preview entity, or null when the resolved body Setup - /// isn't in the dat source (a corrupted/incomplete install — the same - /// failure shape treats - /// as "drop this spawn"). + /// Retail's LIVE idle-loop animation DID enum key (m_didAnimation, + /// the one gmCG3DView::StartAnimation plays at 30fps) — 0x10000006 + /// for every standard heritage, matching gmCG3DView's ctor / + /// ::Update per-heritage assignment (pseudo-C ~0x004ee6cc, + /// ~0x004eec2d). Olthoi and OlthoiAcid use the SAME did for BOTH idle + /// and rest (0x10000011 / 0x10000013 respectively, pseudo-C + /// ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8) — a genuine retail + /// quirk, not a porting shortcut: those two heritages show no visible + /// difference between "idle playing" and "zoomed in and frozen" in the + /// chargen preview. + /// + private static uint ResolveIdleAnimEnum(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => 0x10000011u, + (uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u, + _ => 0x10000006u, + }; + + /// + /// Builds the STATIC (held rest-pose) preview entity, or null when the + /// resolved body Setup isn't in the dat source (a corrupted/incomplete + /// install — the same failure shape + /// treats as "drop this + /// spawn"). Unchanged since CC6a — a thin wrapper over + /// that keeps this method's existing + /// callers' behavior byte-identical. New code that wants retail's true + /// default (idle loop playing) should call + /// and wrap the result in a + /// instead. /// /// /// Shared exclusion object for every dat read this method performs. @@ -79,21 +170,48 @@ internal static class ChargenPreviewEntityBuilder uint heritageId, Quaternion heading, object datLock) + { + ChargenPreviewAnimatedBuild? build = TryBuildAnimated( + dats, animations, appearance, heritageId, heading, datLock); + if (build is null) + return null; + + build.Entity.MeshRefs = build.RestMeshRefs; + return build.Entity; + } + + /// + /// Builds the preview entity PLUS everything a + /// needs to drive retail's idle-loop ↔ rest-pose swap without re-touching + /// the dat source. The returned + /// is initially posed with + /// (cheap, always available) — 's + /// constructor immediately reposes it to the true retail default (idle + /// frame 0) when an idle Animation resolved. + /// + public static ChargenPreviewAnimatedBuild? TryBuildAnimated( + IDatReaderWriter dats, + IAnimationLoader animations, + ChargenAppearanceResult appearance, + uint heritageId, + Quaternion heading, + object datLock) { ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(animations); ArgumentNullException.ThrowIfNull(appearance); ArgumentNullException.ThrowIfNull(datLock); - List meshRefs; uint setupId = appearance.SetupId; - PaletteOverride? paletteOverride; - PartOverride[] partOverrides; + List drawableParts; + List restMeshRefs; + Animation? idleAnimation; + int idleLowFrame = 0, idleHighFrame = -1; - // Every dat read this method performs — the Setup fetch, the held- - // pose animation resolution, the per-part GfxObj drawable checks, - // and the texture-change surface resolution — happens inside this - // one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve + // Every dat read this method performs — the Setup fetch, both pose + // DID resolutions, the per-part GfxObj drawable checks, and the + // texture-change surface resolution — happens inside this one lock, + // mirroring RetailPaperdollPoseApplicator.Apply's "resolve // everything under lock, then do pure processing" shape. lock (datLock) { @@ -109,12 +227,16 @@ internal static class ChargenPreviewEntityBuilder flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); } - ApplyHeldPose(dats, animations, setup, heritageId, flattened); + // Rest pose: overwrite flattened's transforms with the held + // final frame (no-op — keeps Setup-default transforms — if the + // rest DID or its Animation don't resolve). + ApplyHeldPoseTransforms(dats, animations, setup, ResolveRestPoseEnum(heritageId), flattened); Dictionary>? surfaceOverrides = ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges); - meshRefs = new List(flattened.Count); + drawableParts = new List(flattened.Count); + restMeshRefs = new List(flattened.Count); for (int partIndex = 0; partIndex < flattened.Count; partIndex++) { MeshRef part = flattened[partIndex]; @@ -125,27 +247,52 @@ internal static class ChargenPreviewEntityBuilder if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) overrides = perPart; - meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + restMeshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + + Vector3 defaultScale = partIndex < setup.DefaultScale.Count + ? setup.DefaultScale[partIndex] + : Vector3.One; + drawableParts.Add(new ChargenPreviewDrawablePart(partIndex, part.GfxObjId, defaultScale, overrides)); } - if (meshRefs.Count == 0) + if (drawableParts.Count == 0) return null; - paletteOverride = BuildPaletteOverride(appearance); - partOverrides = BuildPartOverrides(appearance); + // Idle DID: independent lookup, no mutation of flattened. + uint idleDid = RetailHeldPose.ResolvePoseDid(dats, ResolveIdleAnimEnum(heritageId)); + idleAnimation = (idleDid >> 24) == 0x03u ? animations.LoadAnimation(idleDid) : null; + if (idleAnimation is not null && idleAnimation.PartFrames.Count > 0) + { + idleLowFrame = 0; + idleHighFrame = idleAnimation.PartFrames.Count - 1; + } + else + { + idleAnimation = null; + } } - return new WorldEntity + var entity = new WorldEntity { Id = PreviewRenderId, ServerGuid = PreviewServerGuid, SourceGfxObjOrSetupId = setupId, Position = Vector3.Zero, Rotation = heading, - MeshRefs = meshRefs, - PaletteOverride = paletteOverride, - PartOverrides = partOverrides, + MeshRefs = restMeshRefs, + PaletteOverride = BuildPaletteOverride(appearance), + PartOverrides = BuildPartOverrides(appearance), ParentCellId = null, }; + + return new ChargenPreviewAnimatedBuild + { + Entity = entity, + DrawableParts = drawableParts, + RestMeshRefs = restMeshRefs, + IdleAnimation = idleAnimation, + IdleLowFrame = idleLowFrame, + IdleHighFrame = idleHighFrame, + }; } /// No dat access — pure projection of the already-composed @@ -178,8 +325,8 @@ internal static class ChargenPreviewEntityBuilder } /// - /// Overwrites every part's transform from the resolved rest pose's - /// FINAL frame — same "hold the settled last frame at zero frame rate" + /// Overwrites every part's transform from the resolved pose DID's FINAL + /// frame — same "hold the settled last frame at zero frame rate" /// approach as RetailPaperdollPoseApplicator.Apply /// (RedressCreature @ 0x004A3C22), applied to the FULL /// setup-part-indexed array (before drawable filtering) so the index @@ -187,14 +334,14 @@ internal static class ChargenPreviewEntityBuilder /// GfxObj. No-ops (keeps the default placement frame) when the pose /// DID or its animation can't be resolved. /// - private static void ApplyHeldPose( + private static void ApplyHeldPoseTransforms( IDatReaderWriter dats, IAnimationLoader animations, Setup setup, - uint heritageId, + uint poseEnum, List flattened) { - uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId)); + uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum); if ((poseDid >> 24) != 0x03u) return; @@ -214,32 +361,12 @@ internal static class ChargenPreviewEntityBuilder orientation = frame.Frames[index].Orientation; } - Matrix4x4 transform = Matrix4x4.CreateScale(scale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); - flattened[index] = new MeshRef(flattened[index].GfxObjId, transform); + flattened[index] = new MeshRef( + flattened[index].GfxObjId, + RetailHeldPose.ComposePartTransform(scale, origin, orientation)); } } - /// - /// DBCache::GetDIDFromEnumStatic(poseEnum, 7) equivalent — verbatim - /// port of RetailPaperdollPoseApplicator.ResolvePoseDid, - /// parameterized by the target enum key. - /// - private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum) - { - uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; - if (masterDid == 0 - || !dats.Portal.TryGet(masterDid, out var master) - || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) - || !dats.Portal.TryGet(subDid, out var sub)) - { - return 0u; - } - - return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u; - } - /// /// Part-index → (old texture id → new texture id) resolution, verbatim /// port of DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides's diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs index ec6c92c8..fa6a7490 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -14,22 +14,32 @@ namespace AcDream.App.Rendering; /// paperdoll's fixed one. /// /// -/// NOT wired here (CC6b, after CC4 merges per the campaign's parallelism -/// contract): mounting into the authored Appearance/Summary viewport ids -/// (0x100003bb / 0x10000406), spin/color-wheel controls, and -/// the rotate/zoom buttons. This class is a standalone, composition-root- -/// agnostic renderer — nothing in AcDream.App/UI/Layout/ or -/// RetailUiRuntime.cs references it yet. +/// NOT wired here (CC6b page-mount half, after CC4 merges per the +/// campaign's parallelism contract): mounting into the authored +/// Appearance/Summary viewport ids (0x100003bb / 0x10000406) +/// and binding the spin/color-wheel/rotate/zoom widgets to +/// // +/// . This class is a standalone, +/// composition-root-agnostic renderer — nothing in +/// AcDream.App/UI/Layout/ or RetailUiRuntime.cs references it +/// yet. /// /// /// -/// Register row (staged deviation, retired by CC6b): retail plays a -/// live 30fps idle loop in the preview -/// (gmCG3DView's m_didAnimation/m_didAnimArray, -/// set_sequence_animation, distinct from the STATIC -/// m_didAnimationRest this class's entity builder uses). CC6a holds -/// the static rest-pose final frame only — see -/// docs/architecture/retail-divergence-register.md. +/// CC6b (pre-mount half): the preview now HAS a real live idle loop +/// (, retail's m_didAnimation DID +/// at 30fps via set_sequence_animation) instead of the CC6a-only held +/// rest pose — TS-83 is retired. still accepts a +/// static WorldEntity for callers that only want +/// ChargenPreviewEntityBuilder.TryBuild's unchanged rest-pose +/// snapshot; a caller that wants the animated preview constructs a +/// from +/// ChargenPreviewEntityBuilder.TryBuildAnimated and passes its +/// Entity here once — the animator mutates that SAME entity's +/// MeshRefs in place every Tick, and Render reads it +/// fresh (no re-SetPreview needed per frame; see +/// WorldEntity.MeshRefs's own "mutable so the animation tick can +/// replace it each frame" doc comment). /// /// internal sealed class ChargenPreviewRenderer : diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs new file mode 100644 index 00000000..4533d606 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -0,0 +1,114 @@ +using System.Numerics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.App.Rendering; + +/// +/// Retail's toggle direction enum +/// (gmBarberUI::ERotateDirection/gmCGAppearancePage::ERotateDirection +/// typedef alias, acclient.h:6848-6852,6960): Invalid=0, +/// Clockwise=1, CounterClockwise=2. +/// +internal enum ChargenRotateDirection +{ + Invalid = 0, + Clockwise = 1, + CounterClockwise = 2, +} + +/// +/// Presentation-free port of gmCGAppearancePage::Rotate +/// (0x0047CB50) + DoRotation (0x0047CA80) — the +/// button-toggled continuous rotation retail applies to the preview +/// CHARACTER's heading (CPhysicsObj::set_heading inside +/// gmCG3DView::Update, pseudo-C ~0x0047eecf1), not the camera (see +/// 's own doc comment on why rotation +/// lives here instead). Retail drives once per frame from +/// a global-message-3 tick while is set +/// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the +/// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise +/// buttons to and the render loop to . +/// +internal sealed class ChargenPreviewRotationController +{ + /// + /// Rotate's explicit sentinel write + /// (this->m_dLastRotateTime = -1.0, pseudo-C ~0x0047cba7/0x0047cbb1 + /// — the high dword 0xbff00000 paired with a zero low dword is the + /// exact IEEE-754 bit pattern for -1.0) — invalidates the + /// timestamp so the very next resets it to "now" + /// (a zero-length first delta) instead of computing a huge jump from a + /// stale or never-set value. + /// + private const double InvalidTimeSentinel = -1.0; + + private double _lastRotateTime = InvalidTimeSentinel; + private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid; + private bool _rotating; + + public bool IsRotating => _rotating; + public ChargenRotateDirection Direction => _direction; + + /// Retail's m_fCurHeading, degrees, ctor default 0 — + /// applied to the preview entity via MoveToMath.SetHeading + /// (CPhysicsObj::set_heading's exact port). + public float HeadingDegrees { get; private set; } + + /// + /// gmCGAppearancePage::Rotate @ 0x0047CB50: pressing the SAME + /// direction a second time while already rotating STOPS rotation + /// (retail's button-toggle UX); any other press (opposite direction, or + /// starting from stopped) sets that direction and (re)starts, + /// invalidating m_dLastRotateTime per this class's own sentinel + /// doc. + /// + public void Toggle(ChargenRotateDirection direction) + { + if (_rotating && direction == _direction) + { + _rotating = false; + return; + } + _direction = direction; + _lastRotateTime = InvalidTimeSentinel; + _rotating = true; + } + + /// + /// gmCGAppearancePage::DoRotation @ 0x0047CA80: per-tick + /// deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) + /// * 360, added for + /// and subtracted for every other direction (pseudo-C ~0x0047cacd: + /// if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else + /// heading += delta;), then a SINGLE-PASS clamp back into + /// [0, 360) — not a full modulo loop; retail's own tail only + /// adds/subtracts 360 once (pseudo-C ~0x0047caf3-0x0047cb31), which is + /// exactly enough for any realistic per-frame delta and is reproduced + /// here verbatim rather than "improved" into a `%=`. + /// + public void Tick(double now) + { + if (!_rotating) + return; + if (_lastRotateTime <= 0d) + _lastRotateTime = now; + + double deltaDegrees = ((now - _lastRotateTime) / ChargenPreviewCamera.RotationSecondsPerRevolution) * 360.0; + HeadingDegrees = _direction == ChargenRotateDirection.Clockwise + ? HeadingDegrees + (float)deltaDegrees + : HeadingDegrees - (float)deltaDegrees; + + if (HeadingDegrees < 0f) + HeadingDegrees += 360f; + if (HeadingDegrees > 360f) + HeadingDegrees -= 360f; + + _lastRotateTime = now; + } + + /// CPhysicsObj::set_heading's exact quaternion + /// construction — the SAME shared Core primitive retail movement already + /// ports (). + public Quaternion ToOrientation() => + MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees); +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs new file mode 100644 index 00000000..b6240024 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs @@ -0,0 +1,135 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Presentation-free port of gmCGAppearancePage::ZoomIn/ZoomOut +/// (0x0047CF00/0x0047D050) and DoZoomAnimation +/// (0x0047C960): a linear 0.6s tween of the preview camera's eye +/// between (zoomed IN) +/// and (zoomed OUT), +/// driving the SAME zoom-state swap the +/// button presses trigger in retail — immediately, not once the tween +/// finishes (see 's own doc comment). +/// +/// +/// Retail drives once per frame from a global-message-3 +/// tick while m_bShouldZoomAnimate is set +/// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the +/// CC6b page-mount half will bind the Zoom In/Out buttons to +/// / and the render loop to +/// . Direction is always (0,0,0) for this camera +/// (see 's own remarks), so only the eye +/// position tweens — retail's own m_vectCurDirection lerp is a no-op +/// here and is not reproduced. +/// +/// +internal sealed class ChargenPreviewZoomController +{ + /// + /// ZoomIn/ZoomOut's explicit invalidation write + /// (this->m_dAnimDuration = -0.1, pseudo-C ~0x0047cff1/0x0047cffb + /// and ~0x0047d12c/0x0047d136 — the exact IEEE-754 bit pattern for + /// -0.1) so the very next resets the duration + /// to and the + /// start time to "now", matching DoZoomAnimation's own + /// reset-if-invalid guard exactly. + /// + private const double InvalidDurationSentinel = -0.1; + + private readonly uint _heritageId; + private Vector3 _startEye; + private Vector3 _targetEye; + private double _animStartTime; + private double _animDuration; + private bool _shouldAnimate; + private bool _zoomedIn; + + public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera) + { + ArgumentNullException.ThrowIfNull(camera); + _heritageId = heritageId; + Camera = camera; + } + + public ChargenPreviewCamera Camera { get; } + + /// Mirrors retail's m_bZoomedIn — false (not zoomed in) + /// is the ctor-implicit default, matching 's + /// own default (see that class's doc comment for the shared citation). + public bool IsZoomedIn => _zoomedIn; + + /// + /// gmCGAppearancePage::ZoomIn @ 0x0047CF00: no-op if already + /// zoomed in (retail's own early-return guard). Otherwise starts a tween + /// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage + /// profile and swaps to the frozen rest + /// pose IMMEDIATELY (gmCG3DView::StopAnimation's call site, + /// pseudo-C ~0x0047d024, precedes the tween's own completion by + /// definition — it runs once, synchronously, inside ZoomIn + /// itself). + /// + public void ZoomIn(ChargenPreviewAnimator? animator) + { + if (_zoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId)); + _zoomedIn = true; + animator?.SetZoomedIn(true); + } + + /// + /// gmCGAppearancePage::ZoomOut @ 0x0047D050: no-op if not + /// currently zoomed in. Otherwise starts a tween toward the zoomed-OUT + /// per-heritage profile and swaps back to + /// the playing idle loop immediately, mirroring . + /// + public void ZoomOut(ChargenPreviewAnimator? animator) + { + if (!_zoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId)); + _zoomedIn = false; + animator?.SetZoomedIn(false); + } + + private void StartTween(Vector3 targetEye) + { + _startEye = Camera.Eye; + _targetEye = targetEye; + _shouldAnimate = true; + _animDuration = InvalidDurationSentinel; + } + + /// + /// gmCGAppearancePage::DoZoomAnimation @ 0x0047C960: a LINEAR + /// (not eased) lerp of the eye position from m_vectStartPosition + /// to m_vectTargPosition over + /// , clamping + /// t to exactly 1.0 (and clearing m_bShouldZoomAnimate) the + /// tick that reaches or passes the duration — the decomp shows a + /// straight (targ - start) * t + start per axis with no easing + /// curve applied anywhere in this function. + /// + public void Tick(double now) + { + if (!_shouldAnimate) + return; + + if (_animDuration <= 0d) + { + _animDuration = ChargenPreviewCamera.ZoomTweenDurationSeconds; + _animStartTime = now; + } + + double elapsed = now - _animStartTime; + if (elapsed >= _animDuration) + { + _shouldAnimate = false; + elapsed = _animDuration; + } + + float t = (float)(elapsed / _animDuration); + Camera.Eye = Vector3.Lerp(_startEye, _targetEye, t); + } +} diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index a808e483..b2502a1a 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -335,29 +335,11 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator /// /// Retail gmPaperDollUI resolves its held pose with - /// DBCache::GetDIDFromEnumStatic(0x10000005, 7). The master map - /// therefore resolves key 7 to a sub-map, then key 0x10000005 to the - /// Animation DID. + /// DBCache::GetDIDFromEnumStatic(0x10000005, 7) — + /// parameterized by the + /// paperdoll's own fixed enum key. /// - private uint ResolvePoseDid() - { - uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; - if (masterDid == 0 - || !_dats.Portal.TryGet( - masterDid, - out var master) - || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) - || !_dats.Portal.TryGet( - subDid, - out var sub)) - { - return 0u; - } - - return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did) - ? did - : 0u; - } + private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u); public void Apply(WorldEntity doll, uint setupId) { @@ -392,9 +374,7 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator orientation = frame.Frames[index].Orientation; } - Matrix4x4 transform = Matrix4x4.CreateScale(scale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); + Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation); MeshRef source = doll.MeshRefs[index]; reposed.Add(new MeshRef(source.GfxObjId, transform) { diff --git a/src/AcDream.App/Rendering/RetailHeldPose.cs b/src/AcDream.App/Rendering/RetailHeldPose.cs new file mode 100644 index 00000000..c67efe57 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailHeldPose.cs @@ -0,0 +1,61 @@ +using System.Numerics; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Shared primitives behind retail's "resolve a rest-pose DID via master-map +/// slot 7, load its Animation, hold the final frame" algorithm — the +/// mechanism (paperdoll, +/// gmPaperDollUI::RedressCreature @ 0x004A3C22) and +/// (chargen preview, +/// gmCG3DView::StopAnimation @ 0x004EE640) both implement. Extracted +/// per the CC6a review's F11/F12 note ("before adding a FOURTH consumer... a +/// shared RetailHeldPose helper is worth extracting before a fourth +/// held-pose consumer exists") — CC6b's own idle-loop work makes chargen's +/// implementation grow enough that mechanically sharing the two primitives +/// BOTH sites already had byte-identical (DID resolution, final-frame +/// transform composition) is a clean win without forcing the two sites' +/// slightly different per-index LOOP shapes (paperdoll walks an +/// already-built, already-filtered WorldEntity.MeshRefs; chargen +/// walks the pre-filter, Setup-part-indexed scratch list) into one method +/// they don't actually share. +/// +internal static class RetailHeldPose +{ + /// + /// DBCache::GetDIDFromEnumStatic(poseEnum, 7) equivalent: master + /// map → slot 7's sub-map → 's Animation DID. + /// Returns 0 if any link in the chain is missing. MUST be called under + /// the caller's dat lock (see 's + /// datLock doc — DatCollection is not thread-safe). + /// + public static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum) + { + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; + if (masterDid == 0 + || !dats.Portal.TryGet(masterDid, out var master) + || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) + || !dats.Portal.TryGet(subDid, out var sub)) + { + return 0u; + } + + return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u; + } + + /// + /// Retail's per-part pose transform: Scale(defaultScale) * + /// Rotate(orientation) * Translate(origin) — the SAME composition + /// both RetailPaperdollPoseApplicator.Apply and + /// 's pose steps use, whether + /// the (origin, orientation) pair comes from a held final frame or an + /// interpolated idle-cycle frame. + /// + public static Matrix4x4 ComposePartTransform(Vector3 defaultScale, Vector3 origin, Quaternion orientation) => + Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs index d2fa1d29..c7629130 100644 --- a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs +++ b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs @@ -12,14 +12,20 @@ namespace AcDream.Core.CharGen; /// The body Setup dat id (0x02......) to build the preview mesh from — /// gender.SetupId, overridden by the selected hair style's /// AlternateSetup when it is neither 0 nor retail's INVALID_DID -/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), falling back -/// to when the resolved -/// id is 0 OR INVALID_DID (retail: CharGenState::GetSetupID @ -/// 0x005C5B22 and gmCG3DView::Update's own check at -/// ~0x004EEA51/0x004EEA5F both test against INVALID_DID, not zero — -/// acclient.h:39909 types the field as IDClass, whose "unset" -/// value is 0xFFFFFFFF; CPhysicsObj::makeObject(setupId)'s own -/// HUMAN_SETUP_ID fallback, gmCG3DView ctor pseudo-C ~0x004EE79D). +/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), in turn +/// overridden outright by 's +/// own alternateSetupIdOverride parameter when THAT is not +/// INVALID_DID (gmCG3DView::Update's own +/// m_alternateSetupID resolution, ~0x004EEA46-0x004EEA53 — see that +/// parameter's doc for why chargen's own Appearance page never actually sets +/// it), falling back to +/// when the resolved id is STILL 0 OR INVALID_DID after all three +/// tiers (retail: CharGenState::GetSetupID @ 0x005C5B22 and +/// gmCG3DView::Update's own check at ~0x004EEA5F both test against +/// INVALID_DID, not zero — acclient.h:39909 types the field as +/// IDClass, whose "unset" value is 0xFFFFFFFF; +/// CPhysicsObj::makeObject(setupId)'s own HUMAN_SETUP_ID fallback, +/// gmCG3DView ctor pseudo-C ~0x004EE79D). /// /// /// gender.BasePaletteId (retail Sex_CG.BasePalette) — the @@ -136,6 +142,30 @@ public static class ChargenAppearanceFactory /// contribution is skipped, matching retail's own "hash miss → no-op, /// caller never checks BuildObjDesc's return value" behavior. /// + /// + /// Retail's SECOND body-Setup-override source — gmCG3DView's + /// m_alternateSetupID field (default INVALID_DID, read at + /// gmCG3DView::Update @ ~0x004EEA46-0x004EEA53) — which, when set + /// to anything other than INVALID_DID, REPLACES the hairstyle/ + /// gender-resolved Setup id outright rather than combining with it. + /// Decomp-verified NOT to be a character-creation-time mechanism: + /// every write site for m_alternateSetupID (the Penumbraen-crown + /// and Undead-no-flame variants, ~0x004DFB3F/0x004E0C54/0x004E0D42/ + /// 0x004E0DB1) lives on gmBarberUI — the POST-CREATION barber- + /// shop appearance-editing screen, a wholly separate UI class from + /// character creation's gmCGAppearancePage, which has no + /// m_pOption1Checkbox-equivalent field and never writes + /// m_alternateSetupID anywhere in its own methods (confirmed + /// against every field on gmCGAppearancePage, + /// acclient.h:56373-56428). For chargen's own preview, + /// m_alternateSetupID is therefore ALWAYS INVALID_DID in + /// retail, and this parameter's default () + /// reproduces that exactly — a real, decomp-verified precedence tier is + /// threaded through so a future non-chargen consumer of this same + /// factory (e.g. a barber-shop feature, out of Campaign CC's scope) can + /// supply one, without inventing a UI source chargen's own Appearance + /// page doesn't have. + /// public static bool TryCompose( ChargenOptions options, uint heritageId, @@ -143,7 +173,8 @@ public static class ChargenAppearanceFactory ChargenAppearanceSelection selection, IChargenPalSetSource palSets, IChargenClothingTableSource clothingTables, - out ChargenAppearanceResult result) + out ChargenAppearanceResult result, + uint alternateSetupIdOverride = InvalidDid) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(palSets); @@ -170,6 +201,14 @@ public static class ChargenAppearanceFactory if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid) setupId = hairStyle.AlternateSetup; } + + // gmCG3DView::Update @ ~0x004EEA46-0x004EEA53: m_alternateSetupID, + // when set, REPLACES the hairstyle/gender-resolved id outright — it + // does not combine with it. See alternateSetupIdOverride's own doc + // for why chargen's own Appearance page never actually supplies one. + if (alternateSetupIdOverride != InvalidDid) + setupId = alternateSetupIdOverride; + if (setupId == 0 || setupId == InvalidDid) setupId = HumanSetupId; diff --git a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs new file mode 100644 index 00000000..b9a004ff --- /dev/null +++ b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs @@ -0,0 +1,123 @@ +using System; +using System.Numerics; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Physics; + +/// +/// Retail's simplest animation-clip playback shape: advance a frame position +/// at a fixed framerate and wrap it back into [LowFrame, HighFrame], +/// then linearly interpolate one part's origin/orientation between the two +/// bracketing frames. This is the effect of +/// CPhysicsObj::set_sequence_animation (0x0050F6F0) when called +/// with a constant DID and a nonzero framerate and no further motion-command +/// traffic — e.g. gmCG3DView::StartAnimation (0x004EE600), +/// which plays the chargen preview's idle DID at a flat 30 fps with no +/// transitional blending. +/// +/// +/// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as +/// an inline, App-layer-only implementation for the "legacy" (no +/// ) NPC idle-cycle path — +/// LiveEntityAnimationPresenter.Present's non-sequencer branch +/// (CurrFrame += legacyAdvanceSeconds * Framerate with the same +/// modulo wrap) and its private TryResolvePartFrame helper (the same +/// frame-bracket lerp/slerp). That call site has a live entity, a +/// LiveEntityRuntime membership, and per-tick elapsed time supplied by +/// the render loop; the chargen preview has none of that (there is no live +/// entity — character creation hasn't happened yet), so it cannot reuse that +/// class directly. Rather than re-typing the same formula a second time, +/// this Core, pure, unit-testable class is the shared primitive: the +/// chargen preview (AcDream.App.Rendering.ChargenPreviewAnimator) +/// consumes it directly, and it is safe for a future pass to redirect +/// LiveEntityAnimationPresenter's inline copy through it as a +/// behavior-preserving mechanical follow-up (not done here — that file is +/// live, heavily tested production entity-rendering code with zero relation +/// to this preview-only feature, so touching it is out of this slice's +/// blast radius by design, not oversight). +/// +/// +public static class RetailAnimationCyclePlayback +{ + /// + /// Advances by elapsedSeconds * framerate + /// and wraps it back into [lowFrame, highFrame] with the SAME modulo + /// shape LiveEntityAnimationPresenter.Present's legacy branch uses + /// (over % (span + 1), not a plain clamp — a frame position that + /// overshoots the end by more than one span wraps around more than once + /// rather than sticking at the boundary, matching a long stall/resume). + /// Returns unchanged for a degenerate cycle + /// ( <= ), a + /// non-positive , or a non-positive + /// . + /// + public static float Advance( + float currFrame, + int lowFrame, + int highFrame, + float framerate, + float elapsedSeconds) + { + int span = highFrame - lowFrame; + if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f) + return currFrame; + + float next = currFrame + elapsedSeconds * framerate; + if (next > highFrame) + { + float over = next - lowFrame; + next = lowFrame + (over % (span + 1)); + } + else if (next < lowFrame) + { + next = lowFrame; + } + return next; + } + + /// + /// Resolves part 's origin/orientation at + /// by linearly interpolating (lerp origin, + /// slerp orientation) between the frame at floor(currFrame) and + /// the next frame in the cycle (wrapping +1 + /// back to ). Returns false — with + /// default outputs — when is outside + /// the bracketing frame's part list, matching + /// LiveEntityAnimationPresenter.TryResolvePartFrame's no- + /// sequence-frames branch exactly. + /// + public static bool TryInterpolatePart( + Animation animation, + float currFrame, + int lowFrame, + int highFrame, + int partIndex, + out Vector3 origin, + out Quaternion orientation) + { + ArgumentNullException.ThrowIfNull(animation); + + int frameIndex = (int)MathF.Floor(currFrame); + if (frameIndex < lowFrame || frameIndex > highFrame || frameIndex >= animation.PartFrames.Count) + frameIndex = lowFrame; + int nextIndex = frameIndex + 1; + if (nextIndex > highFrame || nextIndex >= animation.PartFrames.Count) + nextIndex = lowFrame; + float t = Math.Clamp(currFrame - frameIndex, 0f, 1f); + + var frames = animation.PartFrames[frameIndex].Frames; + var nextFrames = animation.PartFrames[nextIndex].Frames; + if (partIndex < frames.Count) + { + var first = frames[partIndex]; + var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; + origin = Vector3.Lerp(first.Origin, next.Origin, t); + orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); + return true; + } + + origin = default; + orientation = default; + return false; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs new file mode 100644 index 00000000..b1dc8913 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Hand-built-fixture tests for — no dat +/// access needed, since a can be +/// constructed entirely in memory. Installed-DAT coverage for the RESOLUTION +/// half (ChargenPreviewEntityBuilder.TryBuildAnimated actually finding +/// the idle DID against real dat data) lives in +/// ChargenPreviewEntityBuilderTests. +/// +public sealed class ChargenPreviewAnimatorTests +{ + private static Animation MakeTwoFrameAnim(Vector3 frame0Origin, Vector3 frame1Origin) + { + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = frame0Origin, Orientation = Quaternion.Identity }); + var pf1 = new AnimationFrame(1); + pf1.Frames.Add(new Frame { Origin = frame1Origin, Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + anim.PartFrames.Add(pf1); + return anim; + } + + private static ChargenPreviewAnimatedBuild MakeBuild(Animation? idleAnimation, int idleLow = 0, int idleHigh = 1) + { + const uint gfxObjId = 0x0100_0001u; + var restMeshRefs = new List + { + new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))), // distinct from any idle frame, so tests can tell them apart. + }; + var drawableParts = new List + { + new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null), + }; + var entity = new WorldEntity + { + Id = ChargenPreviewEntityBuilder.PreviewRenderId, + ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = restMeshRefs, + }; + return new ChargenPreviewAnimatedBuild + { + Entity = entity, + DrawableParts = drawableParts, + RestMeshRefs = restMeshRefs, + IdleAnimation = idleAnimation, + IdleLowFrame = idleLow, + IdleHighFrame = idleHigh, + }; + } + + [Fact] + public void Constructor_WithIdleAnimation_SeedsFrameZeroPose_NotTheRestPose() + { + // Retail's true default is the idle loop PLAYING, not the rest pose + // — see ChargenPreviewEntityBuilder's class doc. + var origin0 = new Vector3(1f, 0f, 0f); + var origin1 = new Vector3(5f, 0f, 0f); + var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1)); + + var animator = new ChargenPreviewAnimator(build); + + Assert.False(animator.IsZoomedIn); + Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void Constructor_WithNoIdleAnimation_KeepsTheRestPoseFallback() + { + var build = MakeBuild(idleAnimation: null); + + var animator = new ChargenPreviewAnimator(build); + + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void Tick_AdvancesTheIdleFrame_InterpolatingBetweenFrames() + { + var origin0 = new Vector3(0f, 0f, 0f); + var origin1 = new Vector3(10f, 0f, 0f); + var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1)); + var animator = new ChargenPreviewAnimator(build); + + // 30fps, half a frame's worth of elapsed time -> currFrame 0.5, lerp halfway. + animator.Tick(1f / 60f); + + Assert.Equal(5f, animator.Entity.MeshRefs[0].PartTransform.Translation.X, 3); + } + + [Fact] + public void SetZoomedIn_True_SwapsToTheFrozenRestPoseImmediately() + { + var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f))); + var animator = new ChargenPreviewAnimator(build); + + animator.SetZoomedIn(true); + + Assert.True(animator.IsZoomedIn); + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void Tick_WhileZoomedIn_DoesNotAdvanceTheFrozenPose() + { + var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f))); + var animator = new ChargenPreviewAnimator(build); + animator.SetZoomedIn(true); + + animator.Tick(10f); // large elapsed time — must still be a no-op while zoomed in. + + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void SetZoomedIn_False_RestartsTheIdleLoopAtFrameZero() + { + var origin0 = new Vector3(1f, 0f, 0f); + var origin1 = new Vector3(5f, 0f, 0f); + var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1)); + var animator = new ChargenPreviewAnimator(build); + + animator.Tick(1f / 30f); // advance to frame 1. + animator.SetZoomedIn(true); + animator.SetZoomedIn(false); // gmCG3DView::StartAnimation restarts the clip (clear-then-append). + + Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void SetZoomedIn_SameStateTwice_IsANoOp() + { + var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f))); + var animator = new ChargenPreviewAnimator(build); + + animator.Tick(1f / 60f); // partway through frame 0->1. + Vector3 beforeX = animator.Entity.MeshRefs[0].PartTransform.Translation; + animator.SetZoomedIn(false); // already not zoomed in — must not restart the loop. + + Assert.Equal(beforeX, animator.Entity.MeshRefs[0].PartTransform.Translation); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs index 656b27eb..4e59b9a5 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -124,4 +124,155 @@ public sealed class ChargenPreviewEntityBuilderTests Assert.NotNull(entity); Assert.NotEmpty(entity!.MeshRefs); } + + /// + /// CC6b: TryBuildAnimated resolves a real idle Animation (retail's + /// m_didAnimation) against the installed EoR dat, with a usable + /// frame range and a non-empty drawable-part list a + /// ChargenPreviewAnimator can drive. + /// + [Fact] + public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); + Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)); + + var catalog = new ChargenAppearanceCatalog(adapter); + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + var animations = new RetailAnimationLoader(adapter); + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object()); + + Assert.NotNull(build); + Assert.NotEmpty(build!.DrawableParts); + Assert.NotEmpty(build.RestMeshRefs); + Assert.NotNull(build.IdleAnimation); + Assert.True(build.IdleHighFrame >= build.IdleLowFrame); + Assert.True(build.IdleAnimation!.PartFrames.Count > build.IdleHighFrame); + + // Live end-to-end: an Animator built from this resolves a non-empty, + // playable preview — retail's true default (idle playing), not the + // frozen rest pose TryBuild alone still returns. + var animator = new ChargenPreviewAnimator(build); + Assert.False(animator.IsZoomedIn); + Assert.NotEmpty(animator.Entity.MeshRefs); + + animator.Tick(1f / 30f); // one frame's worth — must not throw or empty the mesh. + Assert.NotEmpty(animator.Entity.MeshRefs); + } + + [Fact] + public void TryBuildAnimated_UnknownSetupId_ReturnsNull() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var animations = new RetailAnimationLoader(adapter); + + var bogusAppearance = new ChargenAppearanceResult( + SetupId: 0x0200_FFFFu, + BasePaletteId: 0u, + ObjDesc: ChargenObjDesc.Empty, + MissingPalSetIds: [], + MissingClothingTableIds: [], + ClothingTablesMissingBaseEffectForSetup: []); + + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object()); + + Assert.Null(build); + } + + /// + /// Decomp-verified quirk (gmCG3DView's ctor / ::Update, + /// pseudo-C ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8): Olthoi + /// and OlthoiAcid use the SAME enum key (0x10000011 / 0x10000013) for + /// BOTH the live idle DID (m_didAnimation) and the rest DID + /// (m_didAnimationRest) — every standard heritage uses two + /// DIFFERENT keys (0x10000006 idle vs 0x10000005 rest). This proves the + /// SHARED enum key resolves to a real installed Animation DID (the same + /// RetailHeldPose.ResolvePoseDid call + /// ChargenPreviewEntityBuilder's ResolveIdleAnimEnum AND + /// ResolveRestPoseEnum both return for these two heritages) — the + /// enum-key identity itself is source-verified (both private methods + /// literally return the SAME numeric constant for Olthoi/OlthoiAcid, see + /// their own doc comments), so a single resolution here is enough to + /// confirm the shared key is not a dead/unresolvable id. + /// + [Theory] + [InlineData(0x10000011u)] // Olthoi's shared idle/rest enum key. + [InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key. + public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey) + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + uint did = RetailHeldPose.ResolvePoseDid(adapter, sharedEnumKey); + + Assert.NotEqual(0u, did); + Assert.Equal(0x03u, did >> 24); // resolves to a real Animation DID. + } + + /// + /// Extends + /// to the idle side: TryBuildAnimated resolves a real idle + /// Animation for Olthoi too (not just the rest pose the older + /// TryBuild-only test covers), so an Olthoi + /// ChargenPreviewAnimator actually plays instead of silently + /// falling back to the rest-only pose. + /// + [Fact] + public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi)); + Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male) + || olthoi.GendersByKey.TryGetValue(2, out male)); + Assert.NotNull(male); + int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key; + + var catalog = new ChargenAppearanceCatalog(adapter); + var animations = new RetailAnimationLoader(adapter); + bool composed = ChargenAppearanceFactory.TryCompose( + options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 }, + catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object()); + + Assert.NotNull(build); + Assert.NotNull(build!.IdleAnimation); + + var animator = new ChargenPreviewAnimator(build); + Assert.False(animator.IsZoomedIn); + Assert.NotEmpty(animator.Entity.MeshRefs); + } } diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs new file mode 100644 index 00000000..358cf051 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs @@ -0,0 +1,117 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Pure (no dat access) tests for +/// — the port of gmCGAppearancePage::Rotate/DoRotation +/// (0x0047CB50/0x0047CA80). +/// +public sealed class ChargenPreviewRotationControllerTests +{ + [Fact] + public void Toggle_StartsRotatingInTheGivenDirection() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + + Assert.True(controller.IsRotating); + Assert.Equal(ChargenRotateDirection.Clockwise, controller.Direction); + } + + [Fact] + public void Toggle_SameDirectionWhileRotating_Stops() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Toggle(ChargenRotateDirection.Clockwise); + + Assert.False(controller.IsRotating); + } + + [Fact] + public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Toggle(ChargenRotateDirection.CounterClockwise); + + Assert.True(controller.IsRotating); + Assert.Equal(ChargenRotateDirection.CounterClockwise, controller.Direction); + } + + [Fact] + public void Tick_WhileNotRotating_IsANoOp() + { + var controller = new ChargenPreviewRotationController(); + controller.Tick(100.0); + + Assert.Equal(0f, controller.HeadingDegrees); + } + + [Fact] + public void Tick_FirstCallAfterToggle_ContributesZeroDelta() + { + // Rotate() invalidates m_dLastRotateTime so the very first DoRotation + // tick resets it to "now" rather than computing a huge jump from a + // stale/never-set timestamp. + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(1000.0); + + Assert.Equal(0f, controller.HeadingDegrees); + } + + [Fact] + public void Tick_ClockwiseAdvance_AddsTheExactPerTickFormula() + { + // deltaDegrees = ((now - last) / RotationSecondsPerRevolution) * 360. + // Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid + // guard, same as retail's own sentinel check would if Timer::cur_time + // could ever read exactly zero — never in practice, so tests avoid + // it too). + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. + controller.Tick(11.5); // half a revolution at 3 s/rev. + + Assert.Equal(180f, controller.HeadingDegrees, 3); + } + + [Fact] + public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.CounterClockwise); + controller.Tick(10.0); + controller.Tick(11.5); // would go to -180, wraps to +180. + + Assert.Equal(180f, controller.HeadingDegrees, 3); + } + + [Fact] + public void Tick_AccumulatesAcrossMultipleTicks() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(10.0); + controller.Tick(10.5); // +60 deg. + controller.Tick(11.0); // +60 deg more. + + Assert.Equal(120f, controller.HeadingDegrees, 3); + } + + [Fact] + public void ToOrientation_AtZeroHeading_IsIdentity() + { + var controller = new ChargenPreviewRotationController(); + Quaternion orientation = controller.ToOrientation(); + + Assert.Equal(Quaternion.Identity.X, orientation.X, 4); + Assert.Equal(Quaternion.Identity.Y, orientation.Y, 4); + Assert.Equal(Quaternion.Identity.Z, orientation.Z, 4); + Assert.Equal(Quaternion.Identity.W, orientation.W, 4); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs new file mode 100644 index 00000000..b5061af5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs @@ -0,0 +1,166 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Pure (no dat access) tests for +/// — the port of gmCGAppearancePage::ZoomIn/ZoomOut/ +/// DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) +/// including its immediate wiring into 's +/// idle-loop ↔ rest-pose swap. +/// +public sealed class ChargenPreviewZoomControllerTests +{ + private static ChargenPreviewAnimator MakeAnimator() + { + const uint gfxObjId = 0x0100_0001u; + var restMeshRefs = new System.Collections.Generic.List + { + new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))), + }; + var drawableParts = new System.Collections.Generic.List + { + new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null), + }; + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + var entity = new WorldEntity + { + Id = ChargenPreviewEntityBuilder.PreviewRenderId, + ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = restMeshRefs, + }; + var build = new ChargenPreviewAnimatedBuild + { + Entity = entity, + DrawableParts = drawableParts, + RestMeshRefs = restMeshRefs, + IdleAnimation = anim, + IdleLowFrame = 0, + IdleHighFrame = 0, + }; + return new ChargenPreviewAnimator(build); + } + + [Fact] + public void ZoomIn_StartsATweenTowardTheDefaultEye_AndMarksZoomedIn() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + + controller.ZoomIn(animator: null); + + Assert.True(controller.IsZoomedIn); + // Tween in progress — eye hasn't jumped yet (Tick hasn't run). + Assert.Equal(ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian), camera.Eye); + } + + [Fact] + public void ZoomIn_WhileAlreadyZoomedIn_IsANoOp() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + controller.ZoomIn(animator: null); // real tween: zoomed-out eye -> default eye. + controller.Tick(10.0); + controller.Tick(10.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds + 1.0); // fully complete it. + Vector3 eyeAfterCompletion = camera.Eye; + + controller.ZoomIn(animator: null); // second call — retail's own early-return guard. + controller.Tick(9999.0); // if ZoomIn wrongly armed a tween, this would move the eye. + + Assert.Equal(eyeAfterCompletion, camera.Eye); + } + + [Fact] + public void ZoomOut_WhileNotZoomedIn_IsANoOp() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + Vector3 startEye = camera.Eye; + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + + controller.ZoomOut(animator: null); + + Assert.False(controller.IsZoomedIn); + Assert.Equal(startEye, camera.Eye); + } + + [Fact] + public void Tick_LinearlyInterpolatesTheEye_HalfwayAtHalfTheDuration() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + Vector3 startEye = camera.Eye; // ctor default == the zoomed-IN eye. + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + controller.ZoomIn(animator: null); // reach the "zoomed in" state (zero-distance tween — Eye already there). + Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + controller.ZoomOut(animator: null); // NOW arms a real tween: default eye -> zoomed-out eye. + + controller.Tick(100.0); // seeds the tween's own start time (first tick of a fresh -0.1 sentinel). + controller.Tick(100.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds / 2.0); + + Vector3 expectedHalfway = Vector3.Lerp(startEye, targetEye, 0.5f); + Assert.Equal(expectedHalfway.X, camera.Eye.X, 3); + Assert.Equal(expectedHalfway.Y, camera.Eye.Y, 3); + Assert.Equal(expectedHalfway.Z, camera.Eye.Z, 3); + } + + [Fact] + public void Tick_PastTheFullDuration_ClampsExactlyToTheTargetEye_AndStopsAnimating() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + controller.ZoomIn(animator: null); // reach "zoomed in" (zero-distance). + Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + controller.ZoomOut(animator: null); // arms the real tween toward targetEye. + + controller.Tick(0.0); + controller.Tick(100.0); // way past the 0.6s duration. + + Assert.Equal(targetEye, camera.Eye); + + Vector3 eyeAfterCompletion = camera.Eye; + controller.Tick(200.0); // tween finished — further ticks must not move the eye. + Assert.Equal(eyeAfterCompletion, camera.Eye); + } + + [Fact] + public void ZoomIn_ImmediatelyFreezesTheAnimatorToTheRestPose_BeforeTheTweenCompletes() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + var animator = MakeAnimator(); + + controller.ZoomIn(animator); + + // No Tick() call at all — retail's ZoomIn calls StopAnimation + // synchronously, before the camera tween has advanced a single frame. + Assert.True(animator.IsZoomedIn); + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void ZoomOut_ImmediatelyResumesTheAnimatorsIdleLoop() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + var animator = MakeAnimator(); + controller.ZoomIn(animator); + + controller.ZoomOut(animator); + + Assert.False(animator.IsZoomedIn); + Assert.Equal(new Vector3(1f, 0f, 0f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs index 8f4ffa7b..cff70aa1 100644 --- a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs +++ b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs @@ -302,6 +302,86 @@ public sealed class ChargenAppearanceFactoryTests Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); } + /// + /// CC6b MUST-COVER item 4 — alternateSetupIdOverride (retail's + /// m_alternateSetupID) must WIN outright over the hairstyle's own + /// AlternateSetup when both are supplied, matching + /// gmCG3DView::Update's replace-not-combine precedence + /// (~0x004EEA46-0x004EEA53). + /// + [Fact] + public void TryCompose_AlternateSetupIdOverride_WinsOverHairStyleAlternateSetup() + { + const uint hairStyleSetup = 0x0200_00AAu; + const uint pageLevelOverride = 0x0200_00BBu; + ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: hairStyleSetup)); + var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result, + alternateSetupIdOverride: pageLevelOverride); + + Assert.Equal(pageLevelOverride, result.SetupId); + } + + /// + /// Companion: with NO hair style selected at all (so there is nothing for + /// the page-level override to out-rank), the override still replaces the + /// plain gender.SetupId. + /// + [Fact] + public void TryCompose_AlternateSetupIdOverride_WinsOverPlainGenderSetupId() + { + const uint pageLevelOverride = 0x0200_00CCu; + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing, + out ChargenAppearanceResult result, + alternateSetupIdOverride: pageLevelOverride); + + Assert.Equal(pageLevelOverride, result.SetupId); + } + + /// + /// The default (no override supplied) call shape is unaffected — proves + /// the new trailing parameter is additive, not a behavior change for + /// every existing caller. + /// + [Fact] + public void TryCompose_NoAlternateSetupIdOverrideSupplied_ResolvesAsBefore() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing, + out ChargenAppearanceResult result); + + Assert.Equal(BodySetupId, result.SetupId); + } + + /// + /// An override equal to retail's INVALID_DID sentinel means "no + /// override" (the field's own default), not "adopt 0xFFFFFFFF as the + /// Setup id" — same sentinel discipline as the hairstyle source (F1). + /// + [Fact] + public void TryCompose_AlternateSetupIdOverrideIsInvalidDid_IsTreatedAsNoOverride() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing, + out ChargenAppearanceResult result, + alternateSetupIdOverride: 0xFFFFFFFFu); + + Assert.Equal(BodySetupId, result.SetupId); + } + [Fact] public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald() { diff --git a/tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs b/tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs new file mode 100644 index 00000000..7b1987bc --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs @@ -0,0 +1,153 @@ +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// is the shared advance-with-wrap +/// + lerp/slerp primitive behind the chargen preview's idle loop +/// (ChargenPreviewAnimator) — the SAME arithmetic +/// LiveEntityAnimationPresenter.Present's legacy (no- +/// ) branch already carries for NPC idle +/// cycles, extracted here so a second, live-entity-free consumer (the +/// chargen preview, which has no LiveEntityRuntime membership to hang +/// a sequencer off of) doesn't retype the formula. +/// +public sealed class RetailAnimationCyclePlaybackTests +{ + private static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation) + { + var anim = new Animation(); + for (int f = 0; f < numFrames; f++) + { + var pf = new AnimationFrame((uint)numParts); + for (int p = 0; p < numParts; p++) + pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation }); + anim.PartFrames.Add(pf); + } + return anim; + } + + [Fact] + public void Advance_WithinSpan_AddsElapsedTimesFramerate() + { + float result = RetailAnimationCyclePlayback.Advance( + currFrame: 5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.1f); + + Assert.Equal(8f, result, precision: 4); // 5 + 0.1*30 = 8. + } + + [Fact] + public void Advance_PastHighFrame_WrapsBackToLowFrame() + { + // 29-frame span (0..29 inclusive = 30 frames), advancing from frame + // 28 by one second at 30fps overshoots by (28+30)-29 = 29, wrapping + // to lowFrame + (29 % 30) = 29... use a case with a clean wrap. + float result = RetailAnimationCyclePlayback.Advance( + currFrame: 25f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.2f); + + // 25 + 6 = 31, over highFrame(29) by span+1=30: over = 31-0 = 31, + // wrapped = 0 + (31 % 30) = 1. + Assert.Equal(1f, result, precision: 4); + } + + [Fact] + public void Advance_BelowLowFrame_ClampsToLowFrame() + { + float result = RetailAnimationCyclePlayback.Advance( + currFrame: -5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.05f); + + // -5 + 1.5 = -3.5, still below lowFrame(0) -> clamp. + Assert.Equal(0f, result); + } + + [Theory] + [InlineData(0, 0)] // degenerate span (highFrame == lowFrame). + [InlineData(0, -1)] // inverted span. + public void Advance_DegenerateSpan_ReturnsCurrFrameUnchanged(int lowFrame, int highFrame) + { + float result = RetailAnimationCyclePlayback.Advance( + currFrame: 3f, lowFrame, highFrame, framerate: 30f, elapsedSeconds: 1f); + + Assert.Equal(3f, result); + } + + [Fact] + public void Advance_NonPositiveFramerateOrElapsed_ReturnsCurrFrameUnchanged() + { + Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 0f, elapsedSeconds: 1f)); + Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: 0f)); + Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: -1f)); + } + + [Fact] + public void TryInterpolatePart_ExactFrame_ReturnsThatFramesPose() + { + Animation anim = MakeAnim(3, 2, new Vector3(1f, 2f, 3f), Quaternion.Identity); + + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 1f, lowFrame: 0, highFrame: 2, partIndex: 0, + out Vector3 origin, out Quaternion orientation); + + Assert.True(ok); + Assert.Equal(new Vector3(1f, 2f, 3f), origin); + Assert.Equal(Quaternion.Identity, orientation); + } + + [Fact] + public void TryInterpolatePart_BetweenFrames_LerpsOriginHalfway() + { + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); + var pf1 = new AnimationFrame(1); + pf1.Frames.Add(new Frame { Origin = new Vector3(10f, 0f, 0f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + anim.PartFrames.Add(pf1); + + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 0.5f, lowFrame: 0, highFrame: 1, partIndex: 0, + out Vector3 origin, out _); + + Assert.True(ok); + Assert.Equal(new Vector3(5f, 0f, 0f), origin); + } + + [Fact] + public void TryInterpolatePart_AtHighFrame_WrapsNextFrameToLowFrame() + { + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity }); + var pf1 = new AnimationFrame(1); + pf1.Frames.Add(new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + anim.PartFrames.Add(pf1); + + // currFrame exactly at highFrame(1): frameIndex=1, nextIndex would be + // 2 which is > highFrame -> wraps to lowFrame(0). t=0 so origin==frame[1]. + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 1f, lowFrame: 0, highFrame: 1, partIndex: 0, + out Vector3 origin, out _); + + Assert.True(ok); + Assert.Equal(new Vector3(2f, 0f, 0f), origin); + } + + [Fact] + public void TryInterpolatePart_PartIndexOutOfRange_ReturnsFalse() + { + Animation anim = MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); + + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 0f, lowFrame: 0, highFrame: 1, partIndex: 5, + out Vector3 origin, out Quaternion orientation); + + Assert.False(ok); + Assert.Equal(default(Vector3), origin); + Assert.Equal(default(Quaternion), orientation); + } +} From 7b52b80c3fb057079cf850f6f98a0e945717e6d3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:10:24 +0200 Subject: [PATCH 093/138] =?UTF-8?q?test(app)+docs:=20CC4=20REVIEW-CLOSED?= =?UTF-8?q?=20=E2=80=94=20R5=20chargen=20root=20extent=20pinned=20by=20obs?= =?UTF-8?q?ervation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final CC4 re-review closed R1-R4 (the fixed-canvas arbiter, headless create-gate proof, #402 filing) and left one residual: the arbiter's mismatch-throw makes 'both char-select screens author 800x600' a crash premise on the exact user-gate path (ACDREAM_OPEN_CHARGEN=1 -> char-management declares -> chargen declares on top), and only char-management's extent was DAT-pinned. The chargen live-DAT probe now pins the root at 800x600 the same way — measured against the installed DAT (passes 7/7), not inferred. Ledger row flipped to REVIEW-CLOSED with real shas. Co-Authored-By: Claude Fable 5 EOF --- .../2026-08-15-character-creation-campaign.md | 2 +- .../UI/Layout/CharacterCreationLiveDatTests.cs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index a17cd6e0..e76987cc 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 | 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. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | +| CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | | CC5 | — | | | | | CC6a | — | | | | | CC6b | — | | | | diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index cd1135a3..9fd47f01 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -43,6 +43,20 @@ public sealed class CharacterCreationLiveDatTests CharacterCreationUiController.RootElementId, screen.Root.DatElementId); + // CC4 re-review R5: the fixed-canvas arbiter THROWS if two concurrent + // screens declare different sizes, and char-management's root is + // DAT-pinned at 800x600 (CharacterManagementLiveDatTests). Chargen + // declares on top of it on the exact user-gate path, so its authored + // extent must be pinned too — an unequal extent is now a crash at + // Open(), not a cosmetic drift. Observe, don't infer (C4 closeout). + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, + layoutId, + CharacterCreationUiController.RootElementId)); + Assert.Equal(800f, rootInfo.Width); + Assert.Equal(600f, rootInfo.Height); + Assert.IsAssignableFrom( screen.FindElement(CharacterCreationUiController.ProgressBarElementId)); AssertButton(screen, CharacterCreationUiController.BackElementId); From 1ba22a01a8fbb125564775fbf8a6b0cf5e2fb560 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:32:48 +0200 Subject: [PATCH 094/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC6b-PR?= =?UTF-8?q?E=20review=20fix=20round=20=E2=80=94=20F1-F7=20+=20F11=20conces?= =?UTF-8?q?sion=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (BLOCKING, doc-only) — the idle-by-default rationale rested on an unsound "uninitialized C++ member defaults to 0" argument (heap operator-new memory is indeterminate, not zero). Verified and replaced with the real evidence: gmCGAppearancePage::InitializePage @0x0047FDD0 writes an EXPLICIT this->m_bZoomedIn = 0; at 0x004802C3, immediately after that same function points the camera at the zoomed-IN per-heritage eye (0x00480286-0x0048029E). Fixed in all three places: the register's TS-83 retirement clause, ChargenPreviewAnimator's class doc, ChargenPreviewZoomController.IsZoomedIn's doc. Recorded the retail quirk this implies: the character starts framed close-up while not-zoomed-in, so the first Zoom In click (once mounted) tweens close-eye->close-eye (visually null) while still freezing the animation — the port reproduces this faithfully. F2 — ChargenPreviewZoomController and ChargenPreviewAnimator kept independent _zoomedIn bools synced only via a nullable animator parameter, risking desync. Retail's m_bZoomedIn is a single field gating both camera and animation, so the fix makes the animator the sole state owner: ChargenPreviewZoomController now takes its ChargenPreviewAnimator as a required constructor dependency, IsZoomedIn reads straight through to it, and ZoomIn/ZoomOut no longer take a parameter at all — there is no second bool left to disagree. F3 — documented the DoRotation counter-clockwise branch's x87-stack decompiler artifact (BN renders x87_r7_1 = x87_r6_3 at 0x0047CAEB, which would store delta-degrees instead of the timestamp for CCW only); the port already stores "now" in both branches, cited against feedback_bn_decomp_field_names.md. F4 — ChargenPreviewAnimator.ApplyIdleFrame now double-buffers two List instead of allocating fresh every 30fps tick. F5 — filed docs/ISSUES.md #402 tracking the RetailAnimationCyclePlayback / LiveEntityAnimationPresenter duplication as an owned post-CC follow-up, referenced from the new type's own doc. F6 — reworded the ChargenPreviewEntityBuilder.TryBuild "byte-identical" claim to result-identical (TryBuildAnimated now also resolves the idle DID and loads the idle Animation before the wrapper discards them). F7 — added the missing clockwise >360 clamp test (readable decomp polarity, unlike F3's CCW artifact). ALSO — rewrote the CC6b ledger row's m_alternateSetupID MUST-COVER note per the reviewer's F11 concession: all five write sites belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no option-checkbox-equivalent field at all. Added the enclosing-function citations and an explicit directive that CC6b-mount must NOT build a crown/no-flame checkbox on the Appearance page. Tests: ChargenPreviewRotationControllerTests +1 (10 total), ChargenPreviewZoomControllerTests +2 and every case rewritten for the required-animator constructor (9 total). Core.Tests 4786/1 skip (unchanged), Content.Tests 147/0, App.Tests 5152/6 skips (+3) — zero failures in isolation, full solution Release build green. Two pre-existing flakes observed across repeated full-solution runs, neither caused by this round and neither reproducing standalone: Core.Net.Tests' NakEmissionTests loss soak, and Content.Tests' DecodedTextureCacheTests concurrency race. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 33 +++++++++ .../retail-divergence-register.md | 2 +- .../2026-08-15-character-creation-campaign.md | 4 +- .../Rendering/ChargenPreviewAnimator.cs | 24 ++++++- .../Rendering/ChargenPreviewEntityBuilder.cs | 12 ++-- .../ChargenPreviewRotationController.cs | 14 +++- .../Rendering/ChargenPreviewZoomController.cs | 69 +++++++++++++------ .../Physics/RetailAnimationCyclePlayback.cs | 3 +- .../ChargenPreviewRotationControllerTests.cs | 18 +++++ .../ChargenPreviewZoomControllerTests.cs | 66 +++++++++++++----- 10 files changed, 195 insertions(+), 50 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index f209a429..aee2fd79 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,39 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #402 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch + +**Status:** OPEN (post-CC consolidation follow-up) +**Severity:** LOW +**Filed:** 2026-08-15 (Campaign CC slice CC6b-PRE review fix round, F5) +**Component:** `src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs`, +`src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs` + +`RetailAnimationCyclePlayback` (advance-with-wrap + lerp/slerp) is a Core, +pure, unit-tested extraction of the SAME algorithm +`LiveEntityAnimationPresenter.Present`'s legacy (no-`AnimationSequencer`) +branch already carries inline for NPC idle cycles +(`CurrFrame += legacyAdvanceSeconds * Framerate` with the same modulo wrap, +plus its own private `TryResolvePartFrame` doing the same frame-bracket +lerp/slerp). The chargen preview (`ChargenPreviewAnimator`) consumes the +new shared type; the two implementations were deliberately left +un-consolidated at CC6b-PRE — `LiveEntityAnimationPresenter` is live, +heavily-tested, in-flight production entity-rendering code with zero +relation to the preview-only feature that motivated the extraction, so +touching it was judged out of that slice's blast radius. + +That decision has no tracked owner. Someone should, in a dedicated pass +after Campaign CC closes: redirect `LiveEntityAnimationPresenter`'s inline +copy through `RetailAnimationCyclePlayback` (a behavior-preserving +mechanical swap — same formulas, same order of operations) and delete the +duplicate. Verify byte-identical output first (a differential test against +the pre-change behavior over a representative NPC idle set) before landing. + +**Acceptance:** one call site for the advance-with-wrap + lerp/slerp +algorithm; `LiveEntityAnimationPresenter`'s legacy branch calls +`RetailAnimationCyclePlayback` instead of reimplementing it; no behavior +change to any currently-animated NPC. + ## #401 — RetailUi should default ON (opt-out), not per-path forced **Status:** OPEN (product-default decision) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 30946327..3cde18db 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -389,7 +389,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-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-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — which the ctor never explicitly sets away from its zero-initialized default — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index e3ddc703..a8dc6879 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -255,6 +255,6 @@ the user gate. | CC5 | — | | | | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). -**Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-82's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-82 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | -| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | single commit, HEAD of `campaign-cc6a` | Review outstanding (dual-lens Opus pass not yet run this round) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the ctor evidence that `m_bZoomedIn` is one of three consecutive bool bytes the decompiler shows only two of (`m_bShouldZoomAnimate`/`m_bRotating` explicitly zeroed, `m_bZoomedIn` never explicitly touched — the same decompiler-elision class `claude-memory/feedback_bn_decomp_field_names.md` warns about) settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept byte-behavior-identical — a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI::ListenToElementMessage`/`::InitializePage` (confirmed via the enclosing-function scan: `gmBarberUI::SetSelection`/`::Rotate` calls and a `CM_Character::Event_FinishBarber` wire call sit in the SAME function bodies), the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`, which has NO `m_pOption1Checkbox`-equivalent field anywhere in its own field list (`acclient.h:56373-56428`, checked exhaustively) and never writes `m_alternateSetupID` in any of its own methods. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, this campaign's own scope. `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism for a future non-chargen consumer of this same factory, not a fabricated feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (9), `ChargenPreviewZoomControllerTests` (7), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (+14 from the CC6a baseline of 4772/1), Content.Tests 147/0 skips (unchanged — no Content-layer work this round), App.Tests 5149/6 skips (+28 from 5121/6) — zero failures, full solution Release build green. One PRE-EXISTING flake noted, not caused by this round: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` failed once in the full-suite run, passed 1/1 in isolation — a randomized-loss-injection timing flake in the unrelated Core.Net transport suite (zero files under `src/AcDream.Core.Net/` touched this round). **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to the three new controllers' `Tick`/`Toggle`/`ZoomIn`/`ZoomOut` methods, spin controls, color wheels. | +**Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-82's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-82 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | +| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the ctor evidence that `m_bZoomedIn` is one of three consecutive bool bytes the decompiler shows only two of (`m_bShouldZoomAnimate`/`m_bRotating` explicitly zeroed, `m_bZoomedIn` never explicitly touched — the same decompiler-elision class `claude-memory/feedback_bn_decomp_field_names.md` warns about) settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs index 51f144d3..b35b00d1 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs @@ -26,6 +26,17 @@ namespace AcDream.App.Rendering; /// at frame 0 on every transition INTO the playing state for the same /// reason: set_sequence_animation's arg3=1 clears the sequence /// before appending, so every StartAnimation call restarts the clip. +/// The DEFAULT-false claim itself rests on gmCGAppearancePage::InitializePage +/// @ 0x0047FDD0's explicit this->m_bZoomedIn = 0; at +/// 0x004802C3 — written immediately after that same function sets the +/// camera to the zoomed-IN per-heritage eye (0x00480286-0x0048029E), +/// not from the ctor simply never touching the field (heap operator new +/// memory is indeterminate, not zero — that argument doesn't hold on its +/// own). One retail quirk this implies: the character starts framed close-up +/// AND not-zoomed-in at the same time, so the FIRST Zoom In click (once +/// mounted) tweens close-eye→close-eye — visually null — while still +/// freezing the animation; the port reproduces this faithfully rather than +/// treating it as a bug. /// /// /// @@ -47,6 +58,15 @@ internal sealed class ChargenPreviewAnimator private float _currFrame; private bool _zoomedIn; + // Double-buffered so a 30fps Tick doesn't allocate a fresh List + // every frame: one buffer is whatever Entity.MeshRefs currently points + // at (potentially still being read by the renderer's own Render() call + // for this frame), the other is safe to Clear()+refill for the NEXT + // tick and only gets published once fully populated. + private readonly List _meshRefsBufferA = []; + private readonly List _meshRefsBufferB = []; + private bool _nextBufferIsA = true; + public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build) { _build = build ?? throw new ArgumentNullException(nameof(build)); @@ -112,7 +132,9 @@ internal sealed class ChargenPreviewAnimator { DatReaderWriter.DBObjs.Animation animation = _build.IdleAnimation!; IReadOnlyList parts = _build.DrawableParts; - var meshRefs = new List(parts.Count); + List meshRefs = _nextBufferIsA ? _meshRefsBufferA : _meshRefsBufferB; + _nextBufferIsA = !_nextBufferIsA; + meshRefs.Clear(); foreach (ChargenPreviewDrawablePart part in parts) { bool resolved = RetailAnimationCyclePlayback.TryInterpolatePart( diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs index bc040a1d..1d7655b4 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -144,10 +144,14 @@ internal static class ChargenPreviewEntityBuilder /// resolved body Setup isn't in the dat source (a corrupted/incomplete /// install — the same failure shape /// treats as "drop this - /// spawn"). Unchanged since CC6a — a thin wrapper over - /// that keeps this method's existing - /// callers' behavior byte-identical. New code that wants retail's true - /// default (idle loop playing) should call + /// spawn"). Unchanged since CC6a for its RESULT — a thin wrapper over + /// that returns exactly the same + /// WorldEntity (rest-posed) this method's existing callers already + /// expect; ALL 3 of those callers' tests still pass unmodified. Not + /// byte-identical internally any more — + /// also resolves the idle DID and loads the idle Animation before this + /// wrapper discards them, extra dat work the pre-CC6b method never did. + /// New code that wants retail's true default (idle loop playing) should call /// and wrap the result in a /// instead. /// diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs index 4533d606..323fdb19 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -84,7 +84,19 @@ internal sealed class ChargenPreviewRotationController /// [0, 360) — not a full modulo loop; retail's own tail only /// adds/subtracts 360 once (pseudo-C ~0x0047caf3-0x0047cb31), which is /// exactly enough for any realistic per-frame delta and is reproduced - /// here verbatim rather than "improved" into a `%=`. + /// here verbatim rather than "improved" into a `%=`. Fix round F3: Binary + /// Ninja literally renders x87_r7_1 = x87_r6_3 at 0x0047CAEB + /// inside the counter-clockwise branch — reassigning the local that held + /// the "now" timestamp to the just-computed delta-degrees value — which + /// would make the 0x0047CB3D store into m_dLastRotateTime + /// write delta-degrees instead of the timestamp for CCW only; that is an + /// x87-FPU-stack modeling artifact of the decompiler, not real retail + /// behavior (a shipped feature where every counter-clockwise rotation + /// visibly diverges from clockwise is implausible, and + /// claude-memory/feedback_bn_decomp_field_names.md names exactly + /// this x87-stack-register mislabeling as a known decompiler artifact + /// class), so this port stores now into _lastRotateTime + /// unconditionally in BOTH directions. /// public void Tick(double now) { diff --git a/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs index b6240024..1e018063 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs @@ -13,6 +13,24 @@ namespace AcDream.App.Rendering; /// finishes (see 's own doc comment). /// /// +/// One owner of the zoom state (fix round F2): retail's +/// m_bZoomedIn is a SINGLE field on gmCGAppearancePage that +/// gates both the camera target AND the animation swap — there is no way +/// for retail's own camera and animation to disagree about which zoom state +/// they're in. The first cut of this port kept two independent bools (one +/// here, one on ) synced only by +/// / calling a NULLABLE animator +/// parameter — a null pass, or any direct +/// call bypassing this +/// controller, would desync the camera's target from the animation's pose. +/// This class now takes its as a +/// REQUIRED constructor dependency and reads +/// straight through to — the +/// animator is the sole state owner, matching retail's own single-field +/// design, and there is no longer a second bool that could disagree with it. +/// +/// +/// /// Retail drives once per frame from a global-message-3 /// tick while m_bShouldZoomAnimate is set /// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the @@ -38,59 +56,68 @@ internal sealed class ChargenPreviewZoomController private const double InvalidDurationSentinel = -0.1; private readonly uint _heritageId; + private readonly ChargenPreviewAnimator _animator; private Vector3 _startEye; private Vector3 _targetEye; private double _animStartTime; private double _animDuration; private bool _shouldAnimate; - private bool _zoomedIn; - public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera) + public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera, ChargenPreviewAnimator animator) { ArgumentNullException.ThrowIfNull(camera); + ArgumentNullException.ThrowIfNull(animator); _heritageId = heritageId; Camera = camera; + _animator = animator; } public ChargenPreviewCamera Camera { get; } - /// Mirrors retail's m_bZoomedIn — false (not zoomed in) - /// is the ctor-implicit default, matching 's - /// own default (see that class's doc comment for the shared citation). - public bool IsZoomedIn => _zoomedIn; + /// + /// Mirrors retail's m_bZoomedIn — a straight read-through to + /// (see this class's own + /// "one owner" doc above), which itself defaults false per + /// gmCGAppearancePage::InitializePage @ 0x0047FDD0's explicit + /// this->m_bZoomedIn = 0; at 0x004802C3 — written right + /// after that same function points the camera at the zoomed-IN + /// per-heritage eye (0x00480286-0x0048029E). One retail quirk + /// this produces: the character starts framed close-up while + /// NOT-zoomed-in, so the first Zoom In click (once mounted) tweens + /// close-eye→close-eye — visually null — while still freezing the + /// animation; this port reproduces it faithfully. + /// + public bool IsZoomedIn => _animator.IsZoomedIn; /// /// gmCGAppearancePage::ZoomIn @ 0x0047CF00: no-op if already /// zoomed in (retail's own early-return guard). Otherwise starts a tween /// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage - /// profile and swaps to the frozen rest - /// pose IMMEDIATELY (gmCG3DView::StopAnimation's call site, - /// pseudo-C ~0x0047d024, precedes the tween's own completion by - /// definition — it runs once, synchronously, inside ZoomIn - /// itself). + /// profile and swaps the animator to the frozen rest pose IMMEDIATELY + /// (gmCG3DView::StopAnimation's call site, pseudo-C ~0x0047d024, + /// precedes the tween's own completion by definition — it runs once, + /// synchronously, inside ZoomIn itself). /// - public void ZoomIn(ChargenPreviewAnimator? animator) + public void ZoomIn() { - if (_zoomedIn) + if (IsZoomedIn) return; StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId)); - _zoomedIn = true; - animator?.SetZoomedIn(true); + _animator.SetZoomedIn(true); } /// /// gmCGAppearancePage::ZoomOut @ 0x0047D050: no-op if not /// currently zoomed in. Otherwise starts a tween toward the zoomed-OUT - /// per-heritage profile and swaps back to - /// the playing idle loop immediately, mirroring . + /// per-heritage profile and swaps the animator back to the playing idle + /// loop immediately, mirroring . /// - public void ZoomOut(ChargenPreviewAnimator? animator) + public void ZoomOut() { - if (!_zoomedIn) + if (!IsZoomedIn) return; StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId)); - _zoomedIn = false; - animator?.SetZoomedIn(false); + _animator.SetZoomedIn(false); } private void StartTween(Vector3 targetEye) diff --git a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs index b9a004ff..c524e5ee 100644 --- a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs +++ b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs @@ -34,7 +34,8 @@ namespace AcDream.Core.Physics; /// behavior-preserving mechanical follow-up (not done here — that file is /// live, heavily tested production entity-rendering code with zero relation /// to this preview-only feature, so touching it is out of this slice's -/// blast radius by design, not oversight). +/// blast radius by design, not oversight). Tracked as +/// docs/ISSUES.md #402 so the follow-up has an owner. /// /// public static class RetailAnimationCyclePlayback diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs index 358cf051..9cd6158e 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs @@ -103,6 +103,24 @@ public sealed class ChargenPreviewRotationControllerTests Assert.Equal(120f, controller.HeadingDegrees, 3); } + /// + /// F7: exercises the >360 -> -360 clamp arm (pseudo-C + /// ~0x0047cb1e-0x0047cb31), the one with readable decomp polarity — + /// unlike the CCW-branch FPU-stack artifact F3 documents, this branch's + /// test/subtract shape is unambiguous. One large clockwise tick pushes + /// heading past 360 in a single call. + /// + [Fact] + public void Tick_ClockwiseAdvancePast360_ClampsBackBySubtracting360() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. + controller.Tick(10.0 + 3.5); // 3.5s at 3s/rev = 420 deg -> 420, clamped to 60. + + Assert.Equal(60f, controller.HeadingDegrees, 3); + } + [Fact] public void ToOrientation_AtZeroHeading_IsIdentity() { diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs index b5061af5..6172a140 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs @@ -13,7 +13,11 @@ namespace AcDream.App.Tests.Rendering; /// — the port of gmCGAppearancePage::ZoomIn/ZoomOut/ /// DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) /// including its immediate wiring into 's -/// idle-loop ↔ rest-pose swap. +/// idle-loop ↔ rest-pose swap. Fix round F2: the controller now takes its +/// as a required constructor dependency +/// and owns no independent zoom-state bool of its own — every test here +/// builds a real (hand-fixture) animator rather than exercising a +/// camera-only path that no longer exists. /// public sealed class ChargenPreviewZoomControllerTests { @@ -53,14 +57,38 @@ public sealed class ChargenPreviewZoomControllerTests return new ChargenPreviewAnimator(build); } + [Fact] + public void Constructor_NullAnimator_Throws() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + Assert.Throws( + () => new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator: null!)); + } + + [Fact] + public void IsZoomedIn_ReadsThroughToTheAnimator_NoIndependentState() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var animator = MakeAnimator(); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator); + + Assert.False(controller.IsZoomedIn); + + // Flip the animator's OWN state directly (bypassing the controller + // entirely) — since the controller now reads straight through, there + // is nothing to desync. + animator.SetZoomedIn(true); + Assert.True(controller.IsZoomedIn); + } + [Fact] public void ZoomIn_StartsATweenTowardTheDefaultEye_AndMarksZoomedIn() { var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); - var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, MakeAnimator()); - controller.ZoomIn(animator: null); + controller.ZoomIn(); Assert.True(controller.IsZoomedIn); // Tween in progress — eye hasn't jumped yet (Tick hasn't run). @@ -72,13 +100,13 @@ public sealed class ChargenPreviewZoomControllerTests { var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); - var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); - controller.ZoomIn(animator: null); // real tween: zoomed-out eye -> default eye. + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, MakeAnimator()); + controller.ZoomIn(); // real tween: zoomed-out eye -> default eye. controller.Tick(10.0); controller.Tick(10.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds + 1.0); // fully complete it. Vector3 eyeAfterCompletion = camera.Eye; - controller.ZoomIn(animator: null); // second call — retail's own early-return guard. + controller.ZoomIn(); // second call — retail's own early-return guard. controller.Tick(9999.0); // if ZoomIn wrongly armed a tween, this would move the eye. Assert.Equal(eyeAfterCompletion, camera.Eye); @@ -89,9 +117,9 @@ public sealed class ChargenPreviewZoomControllerTests { var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); Vector3 startEye = camera.Eye; - var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, MakeAnimator()); - controller.ZoomOut(animator: null); + controller.ZoomOut(); Assert.False(controller.IsZoomedIn); Assert.Equal(startEye, camera.Eye); @@ -102,10 +130,10 @@ public sealed class ChargenPreviewZoomControllerTests { var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); Vector3 startEye = camera.Eye; // ctor default == the zoomed-IN eye. - var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); - controller.ZoomIn(animator: null); // reach the "zoomed in" state (zero-distance tween — Eye already there). + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, MakeAnimator()); + controller.ZoomIn(); // reach the "zoomed in" state (zero-distance tween — Eye already there). Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); - controller.ZoomOut(animator: null); // NOW arms a real tween: default eye -> zoomed-out eye. + controller.ZoomOut(); // NOW arms a real tween: default eye -> zoomed-out eye. controller.Tick(100.0); // seeds the tween's own start time (first tick of a fresh -0.1 sentinel). controller.Tick(100.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds / 2.0); @@ -120,10 +148,10 @@ public sealed class ChargenPreviewZoomControllerTests public void Tick_PastTheFullDuration_ClampsExactlyToTheTargetEye_AndStopsAnimating() { var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); - var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); - controller.ZoomIn(animator: null); // reach "zoomed in" (zero-distance). + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, MakeAnimator()); + controller.ZoomIn(); // reach "zoomed in" (zero-distance). Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); - controller.ZoomOut(animator: null); // arms the real tween toward targetEye. + controller.ZoomOut(); // arms the real tween toward targetEye. controller.Tick(0.0); controller.Tick(100.0); // way past the 0.6s duration. @@ -139,10 +167,10 @@ public sealed class ChargenPreviewZoomControllerTests public void ZoomIn_ImmediatelyFreezesTheAnimatorToTheRestPose_BeforeTheTweenCompletes() { var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); - var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); var animator = MakeAnimator(); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator); - controller.ZoomIn(animator); + controller.ZoomIn(); // No Tick() call at all — retail's ZoomIn calls StopAnimation // synchronously, before the camera tween has advanced a single frame. @@ -154,11 +182,11 @@ public sealed class ChargenPreviewZoomControllerTests public void ZoomOut_ImmediatelyResumesTheAnimatorsIdleLoop() { var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); - var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); var animator = MakeAnimator(); - controller.ZoomIn(animator); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator); + controller.ZoomIn(); - controller.ZoomOut(animator); + controller.ZoomOut(); Assert.False(animator.IsZoomedIn); Assert.Equal(new Vector3(1f, 0f, 0f), animator.Entity.MeshRefs[0].PartTransform.Translation); From 2388fe7aa7f5c38d6a93254d657b448751d04b31 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:39:16 +0200 Subject: [PATCH 095/138] docs: CC6b-PRE re-review residuals R1/R2 + cross-branch renumbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1: the unsound elided-ctor-byte argument survived at its canonical citation site (ChargenPreviewEntityBuilder's class doc, which the two corrected docs point at) and in the ledger row's Deliverables column, which contradicted its own review-status column. Both now carry the real evidence: InitializePage @0x0047FDD0 writes an explicit m_bZoomedIn = 0 at 0x004802C3. R2: the verified 180-degree initial heading (m_fCurHeading = 180f at 0x00480235 + SetPlayerHeading at 0x0048023F, cross-confirmed at gmBarberUI::PostInit and the summary page) now has a durable home in the CC6b-mount OWED list — without it the mount half ships a character facing away from the camera. Merge prep: the branch-local TS-82 renumbered to TS-84 (the CC4 branch independently allocated TS-82 and landed first) and the branch-local ISSUES #402 renumbered to #403 (same collision, same rule), with the Core doc reference updated. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 2 +- docs/architecture/retail-divergence-register.md | 4 ++-- .../2026-08-15-character-creation-campaign.md | 6 +++--- .../Rendering/ChargenPreviewEntityBuilder.cs | 16 +++++++++------- .../Physics/RetailAnimationCyclePlayback.cs | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index aee2fd79..80145a02 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,7 +24,7 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. -## #402 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch +## #403 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch **Status:** OPEN (post-CC consolidation follow-up) **Severity:** LOW diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3cde18db..4f52be0c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -389,11 +389,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-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-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-82 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | +| TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | | TS-76 | Five Character-tab rows have no acdream consumer at all (research doc §4.2's own "state-only, no consumer" list, narrowed to the ids NOT already closed by Campaign OP's Group-C re-points): "Display 3D Tooltips" (`ShowTooltips`), "Side By Side Vitals" (`SideBySideVitals`), "Display Spell Durations" (`SpellDuration`), "Advanced Combat Interface" (`AdvancedCombatUI`), "Stay in Chat Mode After Sending a Message" (`StayInChatMode`) — retail renders 3D item tooltips, an alternate side-by-side vitals layout, remaining-duration overlays on enchantment icons, an expanded combat panel, and a chat-input-stays-open behavior respectively; acdream has none of the four rendering surfaces and no chat-input-close-on-send behavior to gate in the first place. | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (the rows wire+store only) | Each needs a real UI/behavior feature built before the option means anything — inventing a stand-in now would be exactly the workaround CLAUDE.md forbids. | Toggling any of the five writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes. | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; `EffectInfoRegion::Update @0x004f1c00`; `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `ChatInterface::HandleEnterKey @0x004f52d0`; `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index a8dc6879..c41ea0b8 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -253,8 +253,8 @@ the user gate. | 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 | — | | | | | CC5 | — | | | | -| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). -**Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-82's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-82 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | -| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the ctor evidence that `m_bZoomedIn` is one of three consecutive bool bytes the decompiler shows only two of (`m_bShouldZoomAnimate`/`m_bRotating` explicitly zeroed, `m_bZoomedIn` never explicitly touched — the same decompiler-elision class `claude-memory/feedback_bn_decomp_field_names.md` warns about) settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | +**Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | +| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs index 1d7655b4..df21c5a6 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -69,13 +69,15 @@ internal sealed class ChargenPreviewAnimatedBuild /// CC6b: retail's chargen preview does NOT default to a frozen pose — /// gmCGAppearancePage::Update's own trailing gate /// (~0x0047EF01-0x0047EF12) calls gmCG3DView::StartAnimation (idle -/// loop playing) whenever m_bZoomedIn == 0, and that field is never -/// explicitly initialized away from its zero-initialized default in the -/// ctor (gmCGAppearancePage::gmCGAppearancePage, pseudo-C -/// ~0x0047CD58-0x0047CD64 — m_bShouldZoomAnimate/m_bRotating/ -/// m_bZoomedIn are three consecutive bool bytes the decompiler shows -/// only the first two of, a known decompiler-elision class per -/// claude-memory/feedback_bn_decomp_field_names.md). So retail's +/// loop playing) whenever m_bZoomedIn == 0, and that default is +/// DIRECTLY ASSIGNED, not inherited: +/// gmCGAppearancePage::InitializePage @0x0047FDD0 writes an +/// explicit m_bZoomedIn = 0 at 0x004802C3 (right after +/// setting the camera to the zoomed-IN per-heritage eye at +/// 0x00480286-0x0048029E — the null-tween quirk the zoom +/// controller's doc records). The earlier elided-ctor-byte argument was +/// unsound (heap-new members are indeterminate, not zero) and was +/// replaced by this citation at the CC6b-PRE re-review. So retail's /// chargen preview plays its idle loop (m_didAnimation, 30fps) from /// the very first frame; the REST pose (m_didAnimationRest, held /// final frame, this class's pre-CC6b-only behavior) only appears once the diff --git a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs index c524e5ee..788dfe8f 100644 --- a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs +++ b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs @@ -35,7 +35,7 @@ namespace AcDream.Core.Physics; /// live, heavily tested production entity-rendering code with zero relation /// to this preview-only feature, so touching it is out of this slice's /// blast radius by design, not oversight). Tracked as -/// docs/ISSUES.md #402 so the follow-up has an owner. +/// docs/ISSUES.md #403 so the follow-up has an owner. /// /// public static class RetailAnimationCyclePlayback From 34c6fceab0bc300ab638339b88c5e5f98ae4d724 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 21:00:10 +0200 Subject: [PATCH 096/138] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice?= =?UTF-8?q?=20CC6b-MOUNT=20=E2=80=94=20Appearance=20page=20+=20preview=20m?= =?UTF-8?q?ount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page-mount half CC6b-PRE deferred: CharacterCreationAppearancePage (gender buttons, Face/Clothes sub-tabs, nine spin controls with retail's decrement/increment/select-as-current-part OnClickAt zones, nine color swatches, shade scrollbar, zoom/rotate wiring) plus ChargenPreviewController, which bridges the ChargenPreviewRenderer/ChargenPreviewZoomController camera-injection gap CC6a/CC6b-PRE left open and mounts as the third private creature viewport beside paperdoll/creature-appraisal. Color-wheel scouting (campaign risk item 4): live-DAT probe found every color-wheel-family id resolves through existing DatWidgetFactory mappings (Button/Scrollbar/generic fallback) — no new widget type needed. The @140355 gender-flip-on-init oddity (risk item 5): resolved via decomp alone — gmCharGenMainUI's own ctor calls CharGenState::RandomizeCharacter before any page constructs, so retail's chargen screen is never actually blank on open; the Appearance page's gender-flip code always fires against a real, randomly-rolled gender. Filed AP-214 (acdream doesn't port RandomizeCharacter this round, so it opens honestly blank instead) and AP-215 (two narrow visual substitutions: swatch .Selected highlight vs retail's separate overlay, ordinal labels vs retail's icon-only spins). AD-101 retired: the Heritage page's auto-gender-select interim default is deleted now that the Appearance page's real gender buttons exist. TS-82 narrowed to Summary-only. Scope addendum: ChargenPreviewRotationController's parameterless-constructor default changes from 0f to a new RetailDefaultHeadingDegrees=180f constant (retail's InitializePage override, not the ctor's raw 0) — every real gmCG3DView owner converges on 180 before its first frame, so a controller defaulting to 0 was a trap for future consumers. Runtime 1713/0, Core 4786/1 skip, Content 147/0, App 5220/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1) — zero failures across two clean full-solution runs; the one Core.Net.Tests NakEmissionTests flake observed on a third run is the same pre-existing, previously-documented timing flake (zero files under src/AcDream.Core.Net/ touched, passes 100% in isolation). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 9 +- .../2026-08-15-character-creation-campaign.md | 1 + .../Composition/FrameRootComposition.cs | 3 +- .../InteractionRetainedUiComposition.cs | 2 + .../InteractionUiRuntimeSources.cs | 14 + .../LivePresentationComposition.cs | 70 ++ .../Rendering/ChargenPreviewCamera.cs | 14 + .../Rendering/ChargenPreviewController.cs | 298 ++++++++ .../Rendering/ChargenPreviewRenderer.cs | 25 +- .../ChargenPreviewRotationController.cs | 48 +- src/AcDream.App/Rendering/GameWindow.cs | 11 + .../Rendering/GameWindowLifetime.cs | 4 + .../Layout/CharacterCreationAppearancePage.cs | 692 ++++++++++++++++++ .../Layout/CharacterCreationHeritagePage.cs | 39 +- .../Layout/CharacterCreationUiController.cs | 33 + src/AcDream.App/UI/RetailUiRuntime.cs | 25 + .../ChargenPreviewControllerTests.cs | 283 +++++++ .../ChargenPreviewRotationControllerTests.cs | 46 +- .../Layout/CharacterCreationLiveDatTests.cs | 129 ++++ .../CharacterCreationUiControllerTests.cs | 420 ++++++++++- 20 files changed, 2109 insertions(+), 57 deletions(-) create mode 100644 src/AcDream.App/Rendering/ChargenPreviewController.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index acfa8ed6..20985013 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 79 active rows (AD-101..AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Heritage-page auto-gender-select interim default, the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 78 active rows (AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -195,12 +195,11 @@ readiness/requeue adaptation. See | 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. **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. **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) | --- -## 3. Documented approximation (AP) — 149 active rows (AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 151 active rows (AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -390,6 +389,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | +| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions).** Two narrow, DECIDED substitutions where acdream reaches the same functional selection through a different widget mechanism than retail's own: (1) the nine color swatches (`0x1000030f-0x10000317`) use their own `UiButton.Selected` highlight state for "this is the current color" instead of toggling the separate Type-3 companion overlay element (`0x10000318-0x10000320`) retail's `SetColor @ 0x0047DD50` shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — the composited pixel result is UNVERIFIED to match, not asserted identical (same "measured, not assumed" discipline AD-103's own F5 note established for a different swallowed-child case). (2) the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons is out of this round's scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop; `SetStyleSpinLabel`) | Both substitutions reach the SAME underlying selection (the swatch highlight still shows which color index is active; the ordinal still lets a player cycle deterministically and see which slot they're on) through existing widget primitives (`UiButton.Selected`, `UiButton.Label`) rather than adding new rendering infrastructure (a second overlay-visibility channel, or an icon-texture pipeline) this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a different (simpler) selected-swatch visual and text labels where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close the label half of this row. | `gmCGAppearancePage::SetColor @0x0047DD50` (the `m_tColorWheel` overlay toggle); `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | +| AP-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (this round's placeholder), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | | AP-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) | @@ -406,7 +407,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 (**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-82 | **Filed 2026-08-15 at Campaign CC slice CC4. NARROWED to Summary-only 2026-08-15 at Campaign CC slice CC6b-MOUNT.** The Summary (`0x100003d6`, `gmCGSummaryPage`) page root mounts as an EMPTY, content-inert placeholder — visible/reachable through the master shell's free tab navigation (a player can click the Summary tab and land on a blank page) but with none of retail's own controls built: no name field, no summary listbox, no static preview. Explicitly scoped to CC5 (Summary + the Finish gate's real UI). **The Appearance page (`0x100003d4`, `gmCGAppearancePage`) is CLOSED OUT OF THIS ROW as of CC6b-MOUNT** — it now has real gender/Face-Clothes/spin/color-swatch/shade/zoom/rotate controls and a live 3D preview (`CharacterCreationAppearancePage`), so it is no longer content-inert. The master shell already ports retail's OWN visibility/state-toggle/tab-selection mechanics for the Summary page faithfully — only its CONTENT is stopgapped. | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`_summaryPageRoot`, mounted but no page controller attached) | The explicitly sequenced follow-on slice CC5 owns Summary's content; building it here would duplicate work already scoped to that slice and risk drifting from its own DAT/decomp research (the name-input filter, the summary listbox, the static preview). | A player reaching Summary via free tab navigation sees an empty page instead of retail's controls; Finish stays ghosted (**review fix round F11 (2026-08-15) — corrected cross-reference: this row's OWN CC5 dependency, not AP-211**, which is an unrelated roster-slot-cap local refusal — `CharacterCreationUiController`'s `_finish.OnClick = null` ctor comment names this row directly as the reason Finish has no handler this slice) so no create can complete through this screen until CC5 wires the Summary page's name field and the real Finish gate. | `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slice CC5) | | TS-81 | `0x027A AllegianceLoginNotification`'s retail-faithful two-line chat text (lane C §1.6/§7.1: "is the guid in my cached profile" gate, then a logged-on/logged-off line) is NOT emitted. `RuntimeAllegianceState.ApplyLoginNotification` bumps the snapshot revision only. Retail's own handler chain (`ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0` → `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330` → `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220`) resolves its logged-on/logged-off string via two symbols the Binary Ninja decompiler mis-labels as `gmAllegianceUI::\`vftable'.RecvNotice_PrevSpellTab`/`RecvNotice_UpdateSpellComponents` — a decompiler artifact (the address holds a DAT string-table reference, not those vtable slots; same class CLAUDE.md's BN-literal-0 caution warns about) that must be resolved via `compute_str_hash`/DAT string-table lookup, not guessed. Filed rather than inventing English for the two lines. | `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyLoginNotification`) | CLAUDE.md's "no invented user-visible English ever" rule — the candidate strings are BN-mislabeled and unverified from primary source; guessing here is exactly the negligence the workflow rules forbid. | A player never sees retail's "X has logged on/off" allegiance notice; the event still fires and updates Runtime state (usable for a future bot/UI poll), just with no chat line. | `ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0`; `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330`; `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` | | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | | ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 826eb0ce..5352207f 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -258,3 +258,4 @@ the user gate. **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | +| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice) | (this commit) | OWED (dual-lens review pending — Sonnet-implementation session only) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. Non-headgear spins have no decomp-observable Unset-starting-point case (retail always has a real index by the time the user can click — see AP-214) so a first click from Unset in EITHER direction starts at style 0 (a documented, non-retail-cited edge-case default, not a guess dressed as a citation). **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); forces `SetChoice(FACE)` if Clothes was showing when the gate engages. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins and the GradCircle's own interactive click-to-hue behavior (AP-215 both name this — the GradCircle is currently a non-interactive static container this round, since its own click-to-color-position mapping has no decomp citation yet and the nine swatch buttons already provide a full, decomp-cited color-selection path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it. | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index f84a8a6e..6f1deeb5 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -539,7 +539,8 @@ internal sealed class FrameRootCompositionPhase renderFrameResources, new PrivateEntityViewportFrameGroup( live.PaperdollPresenter, - live.CreatureAppraisalPresenter), + live.CreatureAppraisalPresenter, + live.ChargenPreviewController), retainedGameplayUi, // The ImGui developer-tools frontend was removed at Campaign V // slice V11; this optional hook is unbound until a follow-up diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 84ae9175..53ab9be4 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -996,6 +996,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.CharacterCreationSelectStartArea, late.GameRuntime.CharacterCreationFinish, RequestExit: () => { }, + SetAppearanceIndex: late.GameRuntime.CharacterCreationSetAppearanceIndex, + SetShade: late.GameRuntime.CharacterCreationSetShade, ResolveText: key => { lock (d.DatLock) diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 8ea0680c..f61e0651 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -227,6 +227,20 @@ internal sealed class DeferredGameRuntimeStateCommands Invoke((commands, generation) => commands.CharacterCreation.Finish(generation, confirmUnspentCredits)); + // ── Campaign CC slice CC6b-MOUNT: Appearance page commands ─────────── + + public RuntimeCommandResult CharacterCreationSetAppearanceIndex( + ChargenAppearanceSlot slot, + uint index) => + Invoke((commands, generation) => + commands.CharacterCreation.SetAppearanceIndex(generation, slot, index)); + + public RuntimeCommandResult CharacterCreationSetShade( + ChargenShadeSlot slot, + double value) => + Invoke((commands, generation) => + commands.CharacterCreation.SetShade(generation, slot, value)); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 6c481669..92f2df7b 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -125,6 +125,13 @@ internal sealed record LivePresentationResult( PaperdollFramePresenter? PaperdollPresenter, CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer, CreatureAppraisalFramePresenter? CreatureAppraisalPresenter, + // Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview — + // the renderer (leased/disposed) and the controller (per-frame owner + + // late-bound zoom/rotate control surface) are separate fields because + // ChargenPreviewController does not own the renderer's lifetime (it is + // a leased composition resource, mirroring PaperdollViewportRenderer). + ChargenPreviewRenderer? ChargenPreviewRenderer, + ChargenPreviewController? ChargenPreviewController, WbFrustum EnvCellFrustum, EnvCellRenderer? EnvCellRenderer, LandblockPresentationPipeline LandblockPipeline, @@ -985,6 +992,67 @@ internal sealed class LivePresentationCompositionPhase new RetailCreatureAppraisalCloneFactory( new LiveCreatureAppraisalEntityLookup(liveEntities))); } + + // Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview. + // Same "both arms exist, needs a dispatcher + the retained-UI + // viewport widget" shape as paperdoll/creature-appraisal above — + // this is the THIRD private creature viewport, not a new pattern. + CompositionAcquisitionScope.CompositionAcquisitionLease< + ChargenPreviewRenderer>? chargenPreviewLease = null; + ChargenPreviewController? chargenPreviewController = null; + if (dispatcherLease.Resource is { } chargenDispatcher + && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport) + { + var chargenCamera = new ChargenPreviewCamera(); + chargenPreviewLease = scope.Acquire( + "chargen preview viewport", + () => new ChargenPreviewRenderer( + worldPassScope + ?? throw new InvalidOperationException( + "The graphics backend must publish a world pass scope."), + host.GpuDevice, + host.GpuFrameLifetime, + chargenDispatcher, + foundation.SceneLighting!, + foundation.TextureCache, + foundation.MeshAdapter!, + camera: chargenCamera), + static value => value.Dispose()); + IUiViewportRenderer? previousChargenRenderer = chargenViewport.Renderer; + chargenViewport.Renderer = chargenPreviewLease.Resource; + bindings.AdoptRelease( + "chargen preview viewport target", + () => + { + if (ReferenceEquals(chargenViewport.Renderer, chargenPreviewLease.Resource)) + chargenViewport.Renderer = previousChargenRenderer; + }); + + var chargenCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats); + chargenPreviewController = new ChargenPreviewController( + chargenPreviewLease.Resource, + chargenCamera, + new RetailChargenPreviewFrameView( + chargenViewport, + new RetailChargenPreviewPageVisibility(interaction.RetainedUi.Runtime)), + content.Dats, + content.AnimationLoader, + chargenCatalog, + chargenCatalog, + d.DatLock); + interaction.RetainedUi.Runtime.ChargenPreviewControl = chargenPreviewController; + bindings.AdoptRelease( + "chargen preview control", + () => + { + if (ReferenceEquals( + interaction.RetainedUi.Runtime.ChargenPreviewControl, + chargenPreviewController)) + { + interaction.RetainedUi.Runtime.ChargenPreviewControl = null; + } + }); + } Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); @@ -1291,6 +1359,8 @@ internal sealed class LivePresentationCompositionPhase paperdollPresenter, creatureAppraisalLease?.Resource, creatureAppraisalPresenter, + chargenPreviewLease?.Resource, + chargenPreviewController, envCellFrustum, envCellLease.Resource, landblockPipeline, diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs index 98db610d..5ddabf9f 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -157,6 +157,20 @@ internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamer _camera = new ChargenPreviewCamera(heritageId); } + /// + /// CC6b-MOUNT seam: wraps an EXTERNALLY-owned + /// instead of constructing a private one. + /// needs a settable to tween — the + /// other constructor's private _camera field is unreachable from + /// outside this class, so the page-mount composition (which owns the + /// zoom controller) must supply the SAME camera instance both this + /// adapter and the zoom controller mutate/read. + /// + public ChargenPreviewViewportCamera(ChargenPreviewCamera camera) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); public Vector3 Eye => _camera.Eye; diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs new file mode 100644 index 00000000..91c1e669 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -0,0 +1,298 @@ +using System.Diagnostics; +using System.Numerics; +using AcDream.App.UI; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter; + +namespace AcDream.App.Rendering; + +/// +/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over +/// the CC6a/CC6b-PRE preview foundation. +/// is constructed BEFORE the graphical presentation pipeline exists (early +/// retained-UI composition — see 's +/// own late-bound-Func doc comment), so its zoom/rotate buttons bind against +/// this interface's default no-op-until-assigned shape rather than a +/// concrete renderer reference. +/// constructs the real once the +/// graphics backend exists and assigns it onto the page — mirroring exactly +/// how the paperdoll's viewport.Renderer = paperdollLease.Resource +/// late-assignment already works for a DIFFERENT screen's viewport. +/// +internal interface IChargenPreviewControl +{ + /// + /// Recomposes and rebuilds the preview entity when the heritage/gender/ + /// appearance selection actually changed since the last call (a cheap + /// no-op otherwise). Returns false when the selection cannot be + /// resolved/built (heritage or gender not yet chosen, or a missing dat + /// resource) — the caller (the page) simply leaves the previous frame on + /// screen, matching PaperdollFramePresenter's own + /// "keep the successful doll, retry next visible frame" precedent. + /// + bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection); + + void ZoomIn(); + void ZoomOut(); + void RotateClockwise(); + void RotateCounterClockwise(); +} + +/// Gates the preview's per-frame work on whether the Appearance +/// PAGE (not just the leaf viewport widget) is the currently visible page — +/// mirrors IPaperdollInventoryVisibility's outer-frame gate. +internal interface IChargenPreviewPageVisibility +{ + bool IsVisible { get; } +} + +/// CC6b-MOUNT: narrow seam mirroring IPaperdollFrameView so +/// can be exercised with a fake view +/// in tests. +internal interface IChargenPreviewFrameView +{ + bool TryGetVisibleSize(out int width, out int height); + + void SetTextureHandle(uint textureHandle); +} + +/// Thin adapter over RetailUiRuntime.IsChargenPreviewPageVisible +/// — narrowed to so this +/// Rendering-namespace class doesn't need a direct dependency on the +/// UI/Layout-namespace RetailUiRuntime type beyond the one property +/// read. +internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVisibility +{ + private readonly AcDream.App.UI.RetailUiRuntime _runtime; + + public RetailChargenPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public bool IsVisible => _runtime.IsChargenPreviewPageVisible; +} + +/// Retained-UI visibility + texture publication, mirroring +/// RetailPaperdollFrameView. +internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView +{ + private readonly UiViewport _viewport; + private readonly IChargenPreviewPageVisibility _page; + + public RetailChargenPreviewFrameView( + UiViewport viewport, + IChargenPreviewPageVisibility page) + { + _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); + _page = page ?? throw new ArgumentNullException(nameof(page)); + } + + public bool TryGetVisibleSize(out int width, out int height) + { + width = 0; + height = 0; + if (!_viewport.Visible || !_page.IsVisible) + return false; + + width = (int)_viewport.Width; + height = (int)_viewport.Height; + return true; + } + + public void SetTextureHandle(uint textureHandle) => + _viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle); +} + +/// +/// The real, dat-touching implementation of +/// plus the per-frame owner — +/// constructed once in +/// (same composition scope RetailPaperdollPoseApplicator is built in, +/// which has the real content.Dats/content.AnimationLoader/ +/// d.DatLock) and assigned onto the already-mounted Appearance page. +/// +/// +/// Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap): +/// only ever built its OWN private +/// with no injection seam, but +/// needs a SETTABLE camera to +/// tween. This class owns the ONE +/// instance and hands it to the renderer via the new +/// overload, +/// so both the renderer's draw and the zoom controller's tween read/write +/// the exact same eye position. +/// +/// +/// +/// Rebuild vs per-frame ownership split, decomp-cited (retail +/// gmCGAppearancePage::Update @ 0x0047E8F0): the camera SNAPS to +/// the heritage's default (zoomed-in) eye only on a HERITAGE or GENDER +/// change (the two confirmed direct call sites of the outer Update — +/// InitializePage and the two gender-button handlers, +/// ListenToElementMessage cases 0x9d/0x9e) — spin/color/ +/// shade changes call the narrower SetSelection/SetColor/ +/// SetShade instead, none of which touch m_vectCurPosition. +/// reproduces that split: it always recomposes the +/// ObjDesc/mesh (every appearance field feeds gmCG3DView::Update's +/// rebuild eventually), but only resets the camera when heritage or gender +/// actually changed. m_fCurHeading (this class's +/// ) and m_bZoomedIn +/// (read through ) both live +/// on the PAGE in retail and are NEVER reset by Update — so a fresh +/// (unavoidable: it owns the resolved +/// drawable-part list, which changes with the mesh) is immediately restored +/// to the PREVIOUS zoom state, and the current accumulated heading is passed +/// into the rebuild rather than resetting to the retail default. +/// +/// +internal sealed class ChargenPreviewController : + IChargenPreviewControl, + IPrivateEntityViewportFrame, + IDisposable +{ + private readonly IChargenPreviewRenderer _renderer; + private readonly IChargenPreviewFrameView _view; + private readonly ChargenPreviewCamera _camera; + private readonly ChargenPreviewRotationController _rotation; + private readonly IDatReaderWriter _dats; + private readonly IAnimationLoader _animations; + private readonly IChargenPalSetSource _palSets; + private readonly IChargenClothingTableSource _clothingTables; + private readonly object _datLock; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + + private ChargenPreviewAnimator? _animator; + private ChargenPreviewZoomController? _zoom; + private double _lastElapsedSeconds; + private bool _hasComposed; + private uint _lastHeritageId; + private int _lastGenderKey = -1; + private ChargenAppearanceSelection _lastSelection; + private bool _disposed; + + /// The SAME instance passed to the + /// 's own camera constructor + /// parameter — see this class's own doc comment on why the renderer and + /// the zoom controller must share one mutable camera. + public ChargenPreviewController( + IChargenPreviewRenderer renderer, + ChargenPreviewCamera camera, + IChargenPreviewFrameView view, + IDatReaderWriter dats, + IAnimationLoader animations, + IChargenPalSetSource palSets, + IChargenClothingTableSource clothingTables, + object datLock) + { + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _view = view ?? throw new ArgumentNullException(nameof(view)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _palSets = palSets ?? throw new ArgumentNullException(nameof(palSets)); + _clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + _rotation = new ChargenPreviewRotationController(); + } + + /// Test-observability seam only — production callers use + /// /. + internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false; + + /// Test-observability seam only. + internal Vector3 CameraEye => _camera.Eye; + + public bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection) + { + if (_disposed) + return false; + + if (_hasComposed + && heritageId == _lastHeritageId + && genderKey == _lastGenderKey + && selection.Equals(_lastSelection)) + { + return true; + } + + if (!ChargenAppearanceFactory.TryCompose( + options, heritageId, genderKey, selection, + _palSets, _clothingTables, out ChargenAppearanceResult result)) + { + return false; + } + + Quaternion heading = MoveToMath.SetHeading( + Quaternion.Identity, _rotation.HeadingDegrees); + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + _dats, _animations, result, heritageId, heading, _datLock); + if (build is null) + return false; + + bool wasZoomedIn = _animator?.IsZoomedIn ?? false; + _animator = new ChargenPreviewAnimator(build); + if (wasZoomedIn) + _animator.SetZoomedIn(true); + + bool heritageOrGenderChanged = + !_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey; + if (heritageOrGenderChanged) + _camera.SetHeritage(heritageId); + + // ChargenPreviewZoomController's animator dependency is required at + // construction (fix round F2) — a fresh animator means a fresh + // controller, but it reads IsZoomedIn straight through the animator + // we just restored above, so zoom state itself survives the swap. + _zoom = new ChargenPreviewZoomController(heritageId, _camera, _animator); + + _renderer.SetPreview(_animator.Entity); + _hasComposed = true; + _lastHeritageId = heritageId; + _lastGenderKey = genderKey; + _lastSelection = selection; + return true; + } + + public void ZoomIn() => _zoom?.ZoomIn(); + public void ZoomOut() => _zoom?.ZoomOut(); + public void RotateClockwise() => _rotation.Toggle(ChargenRotateDirection.Clockwise); + public void RotateCounterClockwise() => _rotation.Toggle(ChargenRotateDirection.CounterClockwise); + + public void Render() + { + if (_disposed || !_view.TryGetVisibleSize(out int width, out int height)) + return; + + double now = _clock.Elapsed.TotalSeconds; + float deltaSeconds = (float)Math.Max(0.0, now - _lastElapsedSeconds); + _lastElapsedSeconds = now; + + _animator?.Tick(deltaSeconds); + _rotation.Tick(now); + _zoom?.Tick(now); + if (_animator is not null) + _animator.Entity.Rotation = _rotation.ToOrientation(); + + _view.SetTextureHandle(_renderer.Render(width, height)); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + // The renderer itself is a leased composition resource disposed by + // the composition root (mirrors PaperdollViewportRenderer — this + // class does not own its lifetime, only its per-frame drive). + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs index fa6a7490..91d58ee9 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -5,6 +5,18 @@ using AcDream.Core.World; namespace AcDream.App.Rendering; +/// +/// CC6b-MOUNT: narrow seam mirroring IPaperdollDollRenderer so +/// 's rebuild/render logic can be +/// exercised with a fake in tests without a live GPU device. +/// +internal interface IChargenPreviewRenderer +{ + void SetPreview(WorldEntity? entity); + + uint Render(int width, int height); +} + /// /// Chargen-specific facade over the shared private creature viewport /// () — CC6a's foundation half of @@ -44,6 +56,7 @@ namespace AcDream.App.Rendering; /// internal sealed class ChargenPreviewRenderer : IUiViewportRenderer, + IChargenPreviewRenderer, IDisposable { private readonly PrivateEntityViewportRenderer _renderer; @@ -57,9 +70,17 @@ internal sealed class ChargenPreviewRenderer : SceneLightingUboBinding lightUbo, IEntityTextureLifetime textureLifetime, IWbMeshAdapter meshAdapter, - uint heritageId = 0u) + uint heritageId = 0u, + ChargenPreviewCamera? camera = null) { - _camera = new ChargenPreviewViewportCamera(heritageId); + // CC6b-MOUNT: when a caller supplies its own camera instance (the + // page-mount composition, which needs a SETTABLE camera for + // ChargenPreviewZoomController to tween — see + // ChargenPreviewController's own doc comment), wrap that exact + // instance instead of building a private, unreachable one. + _camera = camera is not null + ? new ChargenPreviewViewportCamera(camera) + : new ChargenPreviewViewportCamera(heritageId); _renderer = new PrivateEntityViewportRenderer( scope, device, diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs index 323fdb19..265c5da4 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -46,12 +46,54 @@ internal sealed class ChargenPreviewRotationController private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid; private bool _rotating; + /// + /// CC6b-MOUNT: retail's true OPERATIVE starting heading — not the ctor's + /// value. gmCGAppearancePage::gmCGAppearancePage @0x0047CCC0 sets + /// m_fCurHeading = 0f at 0x0047CDAC, but + /// gmCGAppearancePage::InitializePage @0x0047FDD0 — which always + /// runs immediately afterward, before the page is ever visible — writes + /// m_fCurHeading = 180f at 0x00480235 and pushes it into the + /// view via gmCG3DView::SetPlayerHeading(m_p3DView, 180f) at + /// 0x0048023F. No player-visible frame of chargen's Appearance + /// preview is EVER rendered at the ctor's 0° — 180° is the only heading a + /// user actually sees. The same override, independently, is what every + /// other gmCG3DView owner does for ITS own instance: + /// gmCGSummaryPage::InitializePage @0x0047BD54 (a separate + /// viewport/page, CC5's scope, not this one) and + /// gmBarberUI::PostInit (~0x004DE330, pushed at + /// 0x004E03B5) both call the identical + /// SetPlayerHeading(m_p3DView, 180f) for their own pages. Since + /// this controller — like retail's m_fCurHeading — is itself the + /// PAGE-level heading owner (not the view's), matching the value every + /// real page converges on before its first frame is the retail-faithful + /// choice; requiring every future mount site to remember a separate + /// "seed to 180" call would be a trap (a forgotten seed silently faces + /// the character away from the camera). + /// + public const float RetailDefaultHeadingDegrees = 180f; + public bool IsRotating => _rotating; public ChargenRotateDirection Direction => _direction; - /// Retail's m_fCurHeading, degrees, ctor default 0 — - /// applied to the preview entity via MoveToMath.SetHeading - /// (CPhysicsObj::set_heading's exact port). + /// Defaults to + /// (see that constant's doc for + /// the full ctor-vs-InitializePage citation) — the value every real + /// mount site should get for free. Tests that exercise the pure + /// rotation/wrap arithmetic pass 0f explicitly for simpler + /// relative-delta assertions; that is a test convenience, not a second + /// retail-cited default. + public ChargenPreviewRotationController( + float initialHeadingDegrees = RetailDefaultHeadingDegrees) + { + HeadingDegrees = initialHeadingDegrees; + } + + /// Retail's m_fCurHeading, degrees — applied to the + /// preview entity via MoveToMath.SetHeading + /// (CPhysicsObj::set_heading's exact port). See + /// for why this controller's + /// parameterless-constructor default is 180, not the ctor's raw 0. + /// public float HeadingDegrees { get; private set; } /// diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ac349c78..7fad2da9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -429,6 +429,11 @@ public sealed class GameWindow : _creatureAppraisalViewportRenderer; private AcDream.App.Rendering.CreatureAppraisalFramePresenter? _creatureAppraisalFramePresenter; + // Campaign CC slice CC6b-MOUNT — the chargen Appearance-page preview, + // same guard/shutdown shape as the paperdoll/creature-appraisal + // viewports above. + private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer; + private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession; @@ -1077,6 +1082,8 @@ public sealed class GameWindow : || _paperdollFramePresenter is not null || _creatureAppraisalViewportRenderer is not null || _creatureAppraisalFramePresenter is not null + || _chargenPreviewRenderer is not null + || _chargenPreviewController is not null || _envCellRenderer is not null || _envCellFrustum is not null || _landblockPresentationPipeline is not null @@ -1114,6 +1121,8 @@ public sealed class GameWindow : _paperdollFramePresenter = result.PaperdollPresenter; _creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer; _creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter; + _chargenPreviewRenderer = result.ChargenPreviewRenderer; + _chargenPreviewController = result.ChargenPreviewController; _envCellFrustum = result.EnvCellFrustum; _envCellRenderer = result.EnvCellRenderer; _landblockPresentationPipeline = result.LandblockPipeline; @@ -1759,6 +1768,8 @@ public sealed class GameWindow : _portalTunnelFallback, _paperdollViewportRenderer, _creatureAppraisalViewportRenderer, + _chargenPreviewRenderer, + _chargenPreviewController, _wbDrawDispatcher, _envCellRenderer, _portalDepthMask, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 92001eae..ca95a57e 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -112,6 +112,8 @@ internal sealed record RenderShutdownRoots( TransferableResourceSlot PortalTunnelFallback, PaperdollViewportRenderer? Paperdoll, CreatureAppraisalViewportRenderer? CreatureAppraisal, + ChargenPreviewRenderer? ChargenPreview, + ChargenPreviewController? ChargenPreviewController, WbDrawDispatcher? DrawDispatcher, EnvCellRenderer? EnvironmentCells, PortalDepthMaskRenderer? PortalDepthMask, @@ -489,6 +491,8 @@ internal static class GameWindowShutdownManifest Hard( "creature appraisal viewport", () => render.CreatureAppraisal?.Dispose()), + Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()), + Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()), Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), Hard("environment cells", () => render.EnvironmentCells?.Dispose()), Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs new file mode 100644 index 00000000..82b4fe60 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -0,0 +1,692 @@ +using System.Globalization; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Appearance page (gmCGAppearancePage, root 0x100003d4) — +/// Campaign CC slice CC6b-MOUNT, the final piece of CC6. Decomp anchors: +/// gmCGAppearancePage::InitializePage @ 0x0047FDD0 (widget ids, the +/// 180° initial heading), ::ListenToElementMessage @ 0x0047EF30 (EVERY +/// dispatch this page implements — gender buttons at cases 0x9d/ +/// 0x9e; Face/Clothes sub-tabs at 0x9f/0xa0; the nine +/// spin controls' decrement/increment arrow children, keyed by PARENT id, at +/// cases 0/1; the same nine spins' own BODY click (select-as- +/// current-part, no index change) at cases 0xa5-0xa9 and the +/// mirrored headgear/shirt/trousers/footwear cases; the nine color swatches +/// at cases 5-0xd -> SetColor(0..8); the shade +/// scrollbar at case 0x17 -> SetShade; rotate at +/// 0x19/0x1a; zoom at 0x1b/0x1c), +/// ::SetColor @ 0x0047DD50 and ::SetShade @ 0x0047C860 (the +/// per-part color/shade routing table this page's switch +/// mirrors, including the NOSE/MOUTH/SKIN-all-route-to-skin-shade quirk and +/// EYES having no shade at all), ::Update @ 0x0047E8F0 (the heritage +/// 6/0xc/0xd Clothes-button + Nose/Mouth-spin hide, Eyes-arrows-disable +/// gate). +/// +/// +/// Spin arrow geometry (live-DAT probe, CC6b-MOUNT): every one of the +/// nine spin elements (0x100003af-b3, b5-b8) is uniformly +/// 200px wide with two LOCALLY-reused arrow child ids (0x1000030a +/// decrement at local x=[80,127), 0x1000030b increment at +/// x=[127,174)) that DatWidgetFactory's UiButton consumes into +/// one flat clickable leaf — there is no separate addressable arrow widget +/// to bind. This page reproduces retail's two-arrow-plus-body-click shape +/// entirely through 's local x coordinate +/// (no new DatWidgetFactory widget type needed — see this campaign's +/// color-wheel scouting finding below, which reached the identical "existing +/// types suffice" conclusion for the whole page). +/// +/// +/// +/// Color-wheel family scouting (campaign plan risk item 4, RESOLVED): +/// a live-DAT probe (CharacterCreationLiveDatTests) found every +/// color-wheel-family id resolves through EXISTING DatWidgetFactory +/// mappings: the nine swatch buttons (0x1000030f-0x10000317, retail's +/// SetColor(0..8) targets) author Type 1 -> UiButton; their +/// nine Type-3 companion "selected" overlays (0x10000318-0x10000320, +/// retail's m_tColorWheel[...][0x10][iCurColor*7]->SetVisible +/// highlight ring) and the GradCircle (0x1000030e) author Type 3 -> +/// UiDatElement; the shade scrollbar (0x10000321) authors +/// Type 0xB -> UiScrollbar, matching the decomp's own +/// DynamicCast(0xb). NO new widget type was added. This page uses the +/// swatch buttons' own state for the +/// highlight instead of toggling the separate companion overlay elements — +/// a documented substitution (same class as AD-103's swallowed-child +/// pattern), not a pixel-identical port of retail's own two-widget +/// mechanism. +/// +/// +/// +/// Icon-only style lists have no name string (register-worthy scope +/// cut): CC1's // +/// carry an IconId, not a name — retail +/// shows an actual icon thumbnail in these four spins (hair/eyes/nose/ +/// mouth). Icon rendering is out of this round's scope; the spin shows a +/// 1-based ordinal instead. The four clothing spins (headgear/shirt/ +/// trousers/footwear) DO carry a real +/// and show it directly. +/// +/// +internal sealed class CharacterCreationAppearancePage : IDisposable +{ + private const uint Unset = RuntimeCharacterCreationAppearance.Unset; + + internal enum Part + { + Hair = 1, + Eyes = 2, + Nose = 3, + Mouth = 4, + Skin = 5, + Headgear = 6, + Shirt = 7, + Trousers = 8, + Footwear = 9, + } + + private enum Choice + { + Face, + Clothes, + } + + internal const uint FemaleButtonId = 0x100003A7u; + internal const uint MaleButtonId = 0x100003A8u; + internal const uint FaceButtonId = 0x100003A9u; + internal const uint ClothesButtonId = 0x100003AAu; + internal const uint FaceChoicesId = 0x100003AEu; + internal const uint ClothesChoicesId = 0x100003B4u; + internal const uint HairSpinId = 0x100003AFu; + internal const uint EyesSpinId = 0x100003B0u; + internal const uint NoseSpinId = 0x100003B1u; + internal const uint MouthSpinId = 0x100003B2u; + internal const uint SkinSpinId = 0x100003B3u; + internal const uint HeadgearSpinId = 0x100003B5u; + internal const uint ShirtSpinId = 0x100003B6u; + internal const uint TrousersSpinId = 0x100003B7u; + internal const uint FootwearSpinId = 0x100003B8u; + internal const uint RotateClockwiseId = 0x10000323u; + internal const uint RotateCounterClockwiseId = 0x10000324u; + internal const uint ZoomInId = 0x10000325u; + internal const uint ZoomOutId = 0x10000326u; + internal const uint GradCircleId = 0x1000030Eu; + internal const uint ShadeScrollId = 0x10000321u; + internal const uint ViewportId = 0x100003BBu; + + /// Retail's nine SetColor(0..8) swatch buttons, in + /// index order — verbatim off ListenToElementMessage's cases + /// 5-0xd (elementId - 0x1000030a). + internal static readonly uint[] SwatchIds = + [ + 0x1000030Fu, 0x10000310u, 0x10000311u, 0x10000312u, 0x10000313u, + 0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u, + ]; + + /// Live-DAT-measured arrow geometry, uniform across all nine + /// spins (every one is 200px wide): decrement child at local + /// x=[80,127), increment child at x=[127,174). Anything outside both + /// zones is the spin's own BODY click (retail cases 0xa5-0xa9 + /// and their headgear/shirt/trousers/footwear mirrors). + private const float DecrementZoneStart = 80f; + private const float IncrementZoneStart = 127f; + private const float IncrementZoneEnd = 174f; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiButton? _femaleButton; + private readonly UiButton? _maleButton; + private readonly UiButton? _faceButton; + private readonly UiButton? _clothesButton; + private readonly UiElement? _faceChoices; + private readonly UiElement? _clothesChoices; + private readonly Dictionary _spins = []; + private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length]; + private readonly UiScrollbar? _shadeScroll; + private readonly UiButton? _rotateClockwise; + private readonly UiButton? _rotateCounterClockwise; + private readonly UiButton? _zoomIn; + private readonly UiButton? _zoomOut; + + private Choice _currentChoice = Choice.Face; + private Part _currentPart = Part.Hair; + private bool _eyesArrowsDisabled; + private bool _disposed; + + /// Late-bound preview control seam — see + /// 's own doc comment for why this + /// page cannot receive the real renderer at construction time. + internal IChargenPreviewControl? PreviewControl { get; set; } + + /// The authored viewport (0x100003bb) — the composition + /// root assigns its Renderer once the graphics backend exists, + /// mirroring the paperdoll's own late viewport.Renderer = ... + /// assignment. + internal UiViewport? Viewport { get; } + + internal CharacterCreationAppearancePage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + + _femaleButton = Find(pageRoot, FemaleButtonId); + if (_femaleButton is not null) + _femaleButton.OnClick = () => _bindings.SelectGender(2u); + _maleButton = Find(pageRoot, MaleButtonId); + if (_maleButton is not null) + _maleButton.OnClick = () => _bindings.SelectGender(1u); + + _faceButton = Find(pageRoot, FaceButtonId); + if (_faceButton is not null) + _faceButton.OnClick = () => SelectChoice(Choice.Face); + _clothesButton = Find(pageRoot, ClothesButtonId); + if (_clothesButton is not null) + _clothesButton.OnClick = () => SelectChoice(Choice.Clothes); + + _faceChoices = Find(pageRoot, FaceChoicesId); + _clothesChoices = Find(pageRoot, ClothesChoicesId); + + BindSpin(pageRoot, HairSpinId, Part.Hair); + BindSpin(pageRoot, EyesSpinId, Part.Eyes); + BindSpin(pageRoot, NoseSpinId, Part.Nose); + BindSpin(pageRoot, MouthSpinId, Part.Mouth); + BindSpin(pageRoot, SkinSpinId, Part.Skin); + BindSpin(pageRoot, HeadgearSpinId, Part.Headgear); + BindSpin(pageRoot, ShirtSpinId, Part.Shirt); + BindSpin(pageRoot, TrousersSpinId, Part.Trousers); + BindSpin(pageRoot, FootwearSpinId, Part.Footwear); + + for (int i = 0; i < SwatchIds.Length; i++) + { + UiButton? swatch = Find(pageRoot, SwatchIds[i]); + if (swatch is null) + continue; + int index = i; + swatch.OnClick = () => SelectColor(index); + _swatches[i] = swatch; + } + + _shadeScroll = Find(pageRoot, ShadeScrollId); + if (_shadeScroll is not null) + _shadeScroll.ScalarChanged = SetShadeFromScalar; + + Viewport = Find(pageRoot, ViewportId); + + _rotateClockwise = Find(pageRoot, RotateClockwiseId); + if (_rotateClockwise is not null) + _rotateClockwise.OnClick = () => PreviewControl?.RotateClockwise(); + _rotateCounterClockwise = Find(pageRoot, RotateCounterClockwiseId); + if (_rotateCounterClockwise is not null) + _rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise(); + _zoomIn = Find(pageRoot, ZoomInId); + if (_zoomIn is not null) + _zoomIn.OnClick = () => PreviewControl?.ZoomIn(); + _zoomOut = Find(pageRoot, ZoomOutId); + if (_zoomOut is not null) + _zoomOut.OnClick = () => PreviewControl?.ZoomOut(); + + ApplyChoiceVisibility(); + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_disposed) + return; + + if (_femaleButton is not null) + _femaleButton.Selected = snapshot.GenderKey == 2u; + if (_maleButton is not null) + _maleButton.Selected = snapshot.GenderKey == 1u; + + // gmCGAppearancePage::Update @ ~0x0047EB46-0x0047EE95: heritage + // 6 (Gearknight) / 0xc (Olthoi) / 0xd (OlthoiAcid) hide the Clothes + // sub-tab (and, with it, every clothing spin behind it), hide the + // Nose/Mouth spins directly, and disable the Eyes spin's arrows — + // none of these three heritages have separate clothing, nose, or + // mouth strip choices. + bool clothesHidden = IsClothesHiddenHeritage(snapshot.HeritageId); + if (_clothesButton is not null) + _clothesButton.Visible = !clothesHidden; + if (_spins.TryGetValue(Part.Nose, out UiButton? noseSpin)) + noseSpin.Visible = !clothesHidden; + if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin)) + mouthSpin.Visible = !clothesHidden; + _eyesArrowsDisabled = clothesHidden; + if (clothesHidden && _currentChoice == Choice.Clothes) + { + // Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes + // unreachable so the page never gets stuck showing a hidden tab. + _currentChoice = Choice.Face; + _currentPart = Part.Hair; + } + ApplyChoiceVisibility(); + + if (TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + RefreshSpins(gender, snapshot.Appearance); + + RefreshColorAndShadeControls(view, snapshot); + RebuildPreview(view, snapshot); + } + + // ── Gender / Face-Clothes sub-tab ────────────────────────────────── + + private void SelectChoice(Choice choice) + { + if (_disposed) + return; + _currentChoice = choice; + // gmCGAppearancePage::ListenToElementMessage cases 0x9f/0xa0: + // Face -> SetSelection(ECG_PARTS_HAIR); Clothes -> + // SetSelection(ECG_PARTS_HEADGEAR). + _currentPart = choice == Choice.Face ? Part.Hair : Part.Headgear; + ApplyChoiceVisibility(); + RefreshColorAndShadeControlsFromLatestSnapshot(); + } + + private void ApplyChoiceVisibility() + { + if (_faceChoices is not null) + _faceChoices.Visible = _currentChoice == Choice.Face; + if (_clothesChoices is not null) + _clothesChoices.Visible = _currentChoice == Choice.Clothes; + if (_faceButton is not null) + _faceButton.Selected = _currentChoice == Choice.Face; + if (_clothesButton is not null) + _clothesButton.Selected = _currentChoice == Choice.Clothes; + } + + // ── Spins (style cycling + select-as-current-part) ───────────────── + + private void BindSpin(UiElement pageRoot, uint id, Part part) + { + UiButton? spin = Find(pageRoot, id); + if (spin is null) + return; + _spins[part] = spin; + + if (part == Part.Skin) + { + // Skin has no style index at all — retail disables its arrow + // children outright (SetAttribute_Bool(...,0xd,1) in + // InitializePage/Update's heritage branches). Every click just + // selects Skin as the current part for the color/shade controls. + spin.OnClickAt = (_, _) => SelectPart(Part.Skin); + return; + } + + spin.OnClickAt = (x, _) => + { + if (x >= DecrementZoneStart && x < IncrementZoneStart) + CycleStyle(part, -1); + else if (x >= IncrementZoneStart && x < IncrementZoneEnd) + CycleStyle(part, +1); + else + SelectPart(part); + }; + } + + private void SelectPart(Part part) + { + if (_disposed) + return; + _currentPart = part; + RefreshColorAndShadeControlsFromLatestSnapshot(); + } + + private void CycleStyle(Part part, int delta) + { + if (_disposed) + return; + if (part == Part.Eyes && _eyesArrowsDisabled) + { + SelectPart(part); + return; + } + + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + + ChargenAppearanceSlot? slot = StyleSlotFor(part); + if (slot is null) + { + SelectPart(part); + return; + } + + int count = StyleCount(part, gender); + uint current = StyleCurrent(part, snapshot.Appearance); + // Headgear alone allows the Unset ("no headgear") ring position — + // CharGenState::SetHeadgearStyle's decomp-derived (count+1)-position + // ring (0..count-1, Unset); every other style spin cycles [0,count). + uint next = CycleIndex(current, delta, count, allowUnset: part == Part.Headgear); + _bindings.SetAppearanceIndex?.Invoke(slot.Value, next); + SelectPart(part); + } + + /// + /// Retail's decomp-derived wrap: reproduces + /// CharGenState::SetHeadgearStyle's literal signed-int32 ring of + /// +1 positions (every real index, plus + /// — decrementing from index 0 lands on Unset, + /// incrementing from Unset lands on index 0, matching + /// ListenToElementMessage's cases 6 exactly). Every other + /// style spin has no decomp-observable Unset-cycling case (retail always + /// has a real 0-based index by the time the user can click — see + /// AP-214's RandomizeCharacter-at-open finding, which acdream + /// does not port this round) — an Unset start there is an edge case + /// retail itself never reaches, so the first click either direction just + /// starts cycling from index 0 rather than reconstructing an unfounded + /// wrap direction. + /// + internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset) + { + if (count <= 0) + return Unset; + + if (allowUnset) + { + int cur = current == Unset ? count : (int)current; + int size = count + 1; + int next = Mod(cur + delta, size); + return next == count ? Unset : (uint)next; + } + + if (current == Unset) + return 0u; + return (uint)Mod((int)current + delta, count); + } + + private static int Mod(int value, int modulus) => + ((value % modulus) + modulus) % modulus; + + // ── Color swatches + shade scroll ─────────────────────────────────── + + private void SelectColor(int index) + { + if (_disposed) + return; + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + ChargenAppearanceSlot? slot = ColorSlotFor(_currentPart); + if (slot is null) + return; + + // gmCGAppearancePage::ListenToElementMessage's swatch cases each + // gate on the current part's own color-list length before calling + // SetColor — a swatch beyond the list clicks through to nothing. + int count = ColorCount(_currentPart, gender); + if (index >= count) + return; + + _bindings.SetAppearanceIndex?.Invoke(slot.Value, (uint)index); + } + + private void SetShadeFromScalar(float scalar) + { + if (_disposed) + return; + ChargenShadeSlot? slot = ShadeSlotFor(_currentPart); + if (slot is null) + return; + _bindings.SetShade?.Invoke(slot.Value, scalar); + } + + private void RefreshColorAndShadeControlsFromLatestSnapshot() + { + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is not null) + RefreshColorAndShadeControls(view, view.Snapshot); + } + + private void RefreshColorAndShadeControls( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart); + uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); + for (int i = 0; i < _swatches.Length; i++) + { + if (_swatches[i] is { } swatch) + swatch.Selected = colorSlot is not null && currentColor == (uint)i; + } + + ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); + if (_shadeScroll is null) + return; + _shadeScroll.Enabled = shadeSlot is not null; + if (shadeSlot is { } slot) + { + double shade = ShadeCurrent(slot, snapshot.Appearance); + float scalar = shade < 0.0 ? 0f : (float)Math.Clamp(shade, 0.0, 1.0); + _shadeScroll.SetScalarPosition(scalar); + } + } + + // ── Spin labels ────────────────────────────────────────────────── + + private void RefreshSpins(ChargenGenderOptions gender, RuntimeCharacterCreationAppearance a) + { + SetStyleSpinLabel(Part.Hair, gender.HairStyles.Count, a.HairStyle); + SetStyleSpinLabel(Part.Eyes, gender.EyeStrips.Count, a.EyesStrip); + SetStyleSpinLabel(Part.Nose, gender.NoseStrips.Count, a.NoseStrip); + SetStyleSpinLabel(Part.Mouth, gender.MouthStrips.Count, a.MouthStrip); + SetGearSpinLabel(Part.Headgear, gender.Headgears, a.HeadgearStyle); + SetGearSpinLabel(Part.Shirt, gender.Shirts, a.ShirtStyle); + SetGearSpinLabel(Part.Trousers, gender.Pants, a.TrousersStyle); + SetGearSpinLabel(Part.Footwear, gender.Footwear, a.FootwearStyle); + } + + private void SetStyleSpinLabel(Part part, int count, uint index) + { + if (!_spins.TryGetValue(part, out UiButton? spin)) + return; + spin.Label = index != Unset && index < (uint)count + ? (index + 1).ToString(CultureInfo.InvariantCulture) + : "-"; + } + + private void SetGearSpinLabel(Part part, IReadOnlyList options, uint index) + { + if (!_spins.TryGetValue(part, out UiButton? spin)) + return; + spin.Label = index != Unset && index < (uint)options.Count + ? options[(int)index].Name + : "None"; + } + + // ── Preview rebuild ────────────────────────────────────────────── + + private void RebuildPreview( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (PreviewControl is null + || snapshot.HeritageId == 0u + || snapshot.GenderKey == 0u) + { + return; + } + + RuntimeCharacterCreationAppearance a = snapshot.Appearance; + var selection = new ChargenAppearanceSelection( + a.EyesStrip, a.NoseStrip, a.MouthStrip, + a.HairStyle, a.HairColor, a.EyeColor, + a.HeadgearStyle, a.HeadgearColor, + a.ShirtStyle, a.ShirtColor, + a.TrousersStyle, a.TrousersColor, + a.FootwearStyle, a.FootwearColor, + a.SkinShade, a.HairShade, a.HeadgearShade, + a.ShirtShade, a.TrousersShade, a.FootwearShade); + + PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection); + } + + // ── Per-part routing tables (retail SetColor @0x0047DD50 / SetShade @0x0047C860) ── + + private static ChargenAppearanceSlot? StyleSlotFor(Part part) => part switch + { + Part.Hair => ChargenAppearanceSlot.HairStyle, + Part.Eyes => ChargenAppearanceSlot.EyesStrip, + Part.Nose => ChargenAppearanceSlot.NoseStrip, + Part.Mouth => ChargenAppearanceSlot.MouthStrip, + Part.Headgear => ChargenAppearanceSlot.HeadgearStyle, + Part.Shirt => ChargenAppearanceSlot.ShirtStyle, + Part.Trousers => ChargenAppearanceSlot.TrousersStyle, + Part.Footwear => ChargenAppearanceSlot.FootwearStyle, + _ => null, // Skin. + }; + + /// Retail's SIX colorable parts (SetColor's cases + /// 0,1,5,6,7,8) — Nose/Mouth/Skin have no color list at all. + private static ChargenAppearanceSlot? ColorSlotFor(Part part) => part switch + { + Part.Hair => ChargenAppearanceSlot.HairColor, + Part.Eyes => ChargenAppearanceSlot.EyeColor, + Part.Headgear => ChargenAppearanceSlot.HeadgearColor, + Part.Shirt => ChargenAppearanceSlot.ShirtColor, + Part.Trousers => ChargenAppearanceSlot.TrousersColor, + Part.Footwear => ChargenAppearanceSlot.FootwearColor, + _ => null, + }; + + /// Retail's SetShade switch: Hair has its own shade; + /// Nose/Mouth/Skin ALL route to skin shade (cases 2/3/4 share one body + /// in the decompiled switch — a genuine retail quirk, not a porting + /// shortcut); Eyes has NO case at all (eye color has no shade + /// indirection anywhere in this campaign's model). + private static ChargenShadeSlot? ShadeSlotFor(Part part) => part switch + { + Part.Hair => ChargenShadeSlot.Hair, + Part.Nose => ChargenShadeSlot.Skin, + Part.Mouth => ChargenShadeSlot.Skin, + Part.Skin => ChargenShadeSlot.Skin, + Part.Headgear => ChargenShadeSlot.Headgear, + Part.Shirt => ChargenShadeSlot.Shirt, + Part.Trousers => ChargenShadeSlot.Trousers, + Part.Footwear => ChargenShadeSlot.Footwear, + _ => null, // Eyes. + }; + + private static int StyleCount(Part part, ChargenGenderOptions gender) => part switch + { + Part.Hair => gender.HairStyles.Count, + Part.Eyes => gender.EyeStrips.Count, + Part.Nose => gender.NoseStrips.Count, + Part.Mouth => gender.MouthStrips.Count, + Part.Headgear => gender.Headgears.Count, + Part.Shirt => gender.Shirts.Count, + Part.Trousers => gender.Pants.Count, + Part.Footwear => gender.Footwear.Count, + _ => 0, + }; + + /// Hair/Eyes have their own real per-gender color lists; + /// the four clothing slots share the gender's single + /// list (register + /// AP-208). + private static int ColorCount(Part part, ChargenGenderOptions gender) => part switch + { + Part.Hair => gender.HairColors.Count, + Part.Eyes => gender.EyeColors.Count, + Part.Headgear or Part.Shirt or Part.Trousers or Part.Footwear => + gender.ClothingColors.Count, + _ => 0, + }; + + private static uint StyleCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch + { + Part.Hair => a.HairStyle, + Part.Eyes => a.EyesStrip, + Part.Nose => a.NoseStrip, + Part.Mouth => a.MouthStrip, + Part.Headgear => a.HeadgearStyle, + Part.Shirt => a.ShirtStyle, + Part.Trousers => a.TrousersStyle, + Part.Footwear => a.FootwearStyle, + _ => Unset, + }; + + private static uint ColorCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch + { + Part.Hair => a.HairColor, + Part.Eyes => a.EyeColor, + Part.Headgear => a.HeadgearColor, + Part.Shirt => a.ShirtColor, + Part.Trousers => a.TrousersColor, + Part.Footwear => a.FootwearColor, + _ => Unset, + }; + + private static double ShadeCurrent(ChargenShadeSlot slot, RuntimeCharacterCreationAppearance a) => slot switch + { + ChargenShadeSlot.Skin => a.SkinShade, + ChargenShadeSlot.Hair => a.HairShade, + ChargenShadeSlot.Headgear => a.HeadgearShade, + ChargenShadeSlot.Shirt => a.ShirtShade, + ChargenShadeSlot.Trousers => a.TrousersShade, + ChargenShadeSlot.Footwear => a.FootwearShade, + _ => 0.0, + }; + + private static bool IsClothesHiddenHeritage(uint heritageId) => + heritageId == (uint)ChargenHeritageGroup.Gearknight + || heritageId == (uint)ChargenHeritageGroup.Olthoi + || heritageId == (uint)ChargenHeritageGroup.OlthoiAcid; + + private static bool TryGetGender( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChargenGenderOptions? gender) + { + gender = null; + if (snapshot.HeritageId == 0u || snapshot.GenderKey == 0u) + return false; + if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage)) + return false; + return heritage.GendersByKey.TryGetValue((int)snapshot.GenderKey, out gender); + } + + private static T? Find(UiElement root, uint id) where T : UiElement => + UiElement.FindDescendant(root, id) as T; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_femaleButton is not null) _femaleButton.OnClick = null; + if (_maleButton is not null) _maleButton.OnClick = null; + if (_faceButton is not null) _faceButton.OnClick = null; + if (_clothesButton is not null) _clothesButton.OnClick = null; + foreach (UiButton spin in _spins.Values) + spin.OnClickAt = null; + _spins.Clear(); + foreach (UiButton? swatch in _swatches) + { + if (swatch is not null) + swatch.OnClick = null; + } + if (_shadeScroll is not null) + _shadeScroll.ScalarChanged = null; + if (_rotateClockwise is not null) _rotateClockwise.OnClick = null; + if (_rotateCounterClockwise is not null) _rotateCounterClockwise.OnClick = null; + if (_zoomIn is not null) _zoomIn.OnClick = null; + if (_zoomOut is not null) _zoomOut.OnClick = null; + // PreviewControl is owned by the composition root (disposed with + // the leased ChargenPreviewRenderer) — just drop the reference. + PreviewControl = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index 659bf90e..26f7eb58 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -137,30 +137,29 @@ internal sealed class CharacterCreationHeritagePage : IDisposable Select(chosen); } + /// + /// Campaign CC slice CC6b-MOUNT: AD-101 RETIRED. The Appearance page's + /// real gender buttons (0x100003a7/0x100003a8) now exist, + /// so this no longer needs to auto-select a gender to keep the + /// Profession/Skills/Town pages usable — gender is a real player choice. + /// Retail's own default here is genuinely NOT blank: CharGenState:: + /// Reset @ 0x005C68A0 calls SetGender(this, 0) (unset), but + /// gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0 calls + /// CharGenState::RandomizeCharacter (0x005c6d80) BEFORE any page + /// constructs — retail's chargen screen always opens with a fully + /// RANDOM heritage/gender/appearance/clothing/template/start-area + /// already rolled (see the ~0x004e81f5-0x004e8218 ctor call, ahead of + /// every page's own InitializePage). acdream does not port + /// RandomizeCharacter this round (register AP-214, the same + /// unported-primitive gap AP-212 already tracks for the Random button) + /// — so acdream's screen opens honestly blank instead, and gender is now + /// the player's first real choice on the Appearance page. + /// private void Select(uint heritageId) { if (_disposed) return; - RuntimeCommandResult result = _bindings.SelectHeritage(heritageId); - if (!result.Accepted) - return; - - // CC4 interim default (register AD-101): the Profession/Skills/Town - // pages this slice builds need heritage+gender both selected - // (RuntimeCharacterCreationState.TrySelectTemplate's gate), but - // gender selection lives on the Appearance page (0x100003a7/a8), - // which stays an inert placeholder until CC6b. Auto-select the - // heritage's first available gender so those pages remain usable; - // CC6b's real gender buttons supersede this and the row retires - // then. - IRuntimeCharacterCreationView? view = _bindings.View(); - if (view is not null - && view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) - && heritage.GendersByKey.Count > 0) - { - int genderKey = heritage.GendersByKey.Keys.Min(); - _bindings.SelectGender((uint)genderKey); - } + _bindings.SelectHeritage(heritageId); } /// diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index ab811feb..7e082c36 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -32,6 +32,11 @@ public sealed record CharacterCreationRuntimeBindings( Func SelectStartArea, Func Finish, Action RequestExit, + /// Campaign CC slice CC6b-MOUNT: the Appearance page's nine + /// spin controls and nine color swatches. + Func? SetAppearanceIndex = null, + /// CC6b-MOUNT: the Appearance page's shade scrollbar. + Func? SetShade = null, /// DAT string lookup (table 0x23000002, the SAME table /// every other ID_CharGen_*/ID_Character* key resolves /// through) — used by the Heritage page's composed description text. @@ -130,6 +135,7 @@ internal sealed class CharacterCreationUiController : IDisposable private readonly CharacterCreationProfessionPage _professionPage; private readonly CharacterCreationSkillsPage _skillsPage; private readonly CharacterCreationTownPage _townPage; + private readonly CharacterCreationAppearancePage _appearancePage; private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; @@ -218,6 +224,7 @@ internal sealed class CharacterCreationUiController : IDisposable _professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings); _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); _townPage = new CharacterCreationTownPage(townPageRoot, bindings); + _appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings); // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. _back.OnClick = OnBack; @@ -247,6 +254,30 @@ internal sealed class CharacterCreationUiController : IDisposable internal UiElement Root => _layout.Root; + /// The authored Appearance-page viewport (0x100003bb) — + /// CC6b-MOUNT's composition root assigns its Renderer once the + /// graphics backend exists (mirrors the paperdoll's own late + /// viewport.Renderer = ... assignment). + internal UiViewport? AppearanceViewport => _appearancePage.Viewport; + + /// CC6b-MOUNT: the late-bound zoom/rotate control surface — + /// see 's own + /// doc comment for why this is assigned after construction rather than + /// threaded through the ctor. + internal AcDream.App.Rendering.IChargenPreviewControl? AppearancePreviewControl + { + get => _appearancePage.PreviewControl; + set => _appearancePage.PreviewControl = value; + } + + /// Gates the Appearance preview's per-frame work on whether + /// that specific page — AND the whole chargen screen — is the one + /// currently showing. Close() only ever hides , + /// not the individual page roots, so a page-root-only check would stay + /// true after the screen closes on the Appearance page. Mirrors the + /// paperdoll's own outer-inventory-frame gate. + internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible; + internal static CharacterCreationUiController? CreateDetached( UiRoot host, ImportedLayout layout, @@ -370,6 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable _professionPage.Refresh(view, snapshot); _skillsPage.Refresh(view, snapshot); _townPage.Refresh(view, snapshot); + _appearancePage.Refresh(view, snapshot); _lastGeneration = snapshot.Generation; _lastRevision = snapshot.Revision; } @@ -437,6 +469,7 @@ internal sealed class CharacterCreationUiController : IDisposable _professionPage.Dispose(); _skillsPage.Dispose(); _townPage.Dispose(); + _appearancePage.Dispose(); _host.RemoveChild(Root); } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 4adbb823..145f168b 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -622,6 +622,31 @@ public sealed class RetailUiRuntime : IDisposable internal CharacterCreationUiController? CharacterCreationController => _characterCreationMount?.Controller; + /// Campaign CC slice CC6b-MOUNT: the Appearance page's authored + /// viewport (0x100003bb) — null until the screen has mounted. + /// Mirrors 's own computed-through + /// shape. + internal UiViewport? ChargenPreviewViewportWidget => + CharacterCreationController?.AppearanceViewport; + + /// CC6b-MOUNT: the late-bound zoom/rotate control surface the + /// composition root assigns once the graphics backend exists. + internal AcDream.App.Rendering.IChargenPreviewControl? ChargenPreviewControl + { + get => CharacterCreationController?.AppearancePreviewControl; + set + { + if (CharacterCreationController is { } controller) + controller.AppearancePreviewControl = value; + } + } + + /// CC6b-MOUNT: whether the Appearance page (specifically) is + /// the one currently showing — false, safely, before the screen mounts. + /// + internal bool IsChargenPreviewPageVisible => + CharacterCreationController?.IsAppearancePageVisible ?? false; + public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs new file mode 100644 index 00000000..0e291b6f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs @@ -0,0 +1,283 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Content; +using AcDream.Content.CharGen; +using AcDream.Content.Vfx; +using AcDream.Core.CharGen; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign CC slice CC6b-MOUNT — installed-DAT gate for +/// 's rebuild/render logic, using fake +/// / +/// implementations (no live GPU device needed — mirrors +/// PaperdollFramePresenterTests's recording-fake pattern) against a +/// REAL dat-backed / +/// so ChargenAppearanceFactory.TryCompose and +/// ChargenPreviewEntityBuilder.TryBuildAnimated actually run. +/// +public sealed class ChargenPreviewControllerTests +{ + private readonly ITestOutputHelper _out; + public ChargenPreviewControllerTests(ITestOutputHelper output) => _out = output; + + private const uint AluvianId = 1u; + private const uint GearknightId = 6u; + + [Fact] + public void Rebuild_SameSelectionTwice_IsANoOpSecondTime() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + ChargenAppearanceSelection selection = DefaultSelection(options, AluvianId, 1); + Assert.True(controller.Rebuild(options, AluvianId, 1, selection)); + Assert.True(controller.Rebuild(options, AluvianId, 1, selection)); + + Assert.Equal(1, renderer.SetPreviewCallCount); + } + } + + [Fact] + public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var camera = new ChargenPreviewCamera(); + var controller = new ChargenPreviewController( + renderer, camera, view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + // Poke the SAME camera instance the controller shares (this is + // exactly the injection seam ChargenPreviewController's own doc + // comment describes) — simulates the camera having drifted away + // from the heritage default (e.g. mid zoom-out tween). + camera.Eye = new Vector3(0f, -99f, 99f); + + if (!options.TryGetHeritage(GearknightId, out ChargenHeritageOptions? gearknight) + || gearknight!.GendersByKey.Count == 0) + { + _out.WriteLine("SKIP: installed dat has no Gearknight gender to switch to."); + return; + } + int gearknightGender = gearknight.GendersByKey.Keys.First(); + Assert.True(controller.Rebuild( + options, GearknightId, gearknightGender, + DefaultSelection(options, GearknightId, gearknightGender))); + + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(GearknightId), controller.CameraEye); + } + } + + /// + /// Decomp-cited (gmCGAppearancePage::Update's two confirmed direct + /// call sites — InitializePage and the gender-button handlers — + /// vs the narrower SetSelection/SetColor/SetShade + /// every spin/color/shade change goes through instead): a rebuild that + /// changes ONLY the appearance selection (same heritage, same gender) + /// must NOT snap the camera back to the heritage default. + /// + [Fact] + public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var camera = new ChargenPreviewCamera(); + var controller = new ChargenPreviewController( + renderer, camera, view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1); + Assert.True(controller.Rebuild(options, AluvianId, 1, first)); + var pokedEye = new Vector3(0f, -99f, 99f); + camera.Eye = pokedEye; + + ChargenAppearanceSelection second = first with { SkinShade = 0.9 }; + Assert.True(controller.Rebuild(options, AluvianId, 1, second)); + + Assert.Equal(pokedEye, controller.CameraEye); + } + } + + [Fact] + public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1); + Assert.True(controller.Rebuild(options, AluvianId, 1, first)); + controller.ZoomIn(); + Assert.True(controller.IsZoomedIn); + + // A DIFFERENT selection, same heritage/gender — retail's + // gmCGAppearancePage::Update only resets the camera POSITION on + // heritage/gender change; m_bZoomedIn is untouched by spin/color/ + // shade edits. + ChargenAppearanceSelection second = first with { SkinShade = 0.9 }; + Assert.True(controller.Rebuild(options, AluvianId, 1, second)); + + Assert.True(controller.IsZoomedIn); + } + } + + [Fact] + public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + Assert.NotNull(renderer.LastEntity); + + controller.Render(); + + Quaternion expected = MoveToMath.SetHeading( + Quaternion.Identity, ChargenPreviewRotationController.RetailDefaultHeadingDegrees); + Quaternion actual = renderer.LastEntity!.Rotation; + Assert.Equal(expected.X, actual.X, 4); + Assert.Equal(expected.Y, actual.Y, 4); + Assert.Equal(expected.Z, actual.Z, 4); + Assert.Equal(expected.W, actual.W, 4); + } + } + + [Fact] + public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView { Visible = false }; + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + + controller.Render(); + + Assert.Equal(0, renderer.RenderCallCount); + Assert.Null(view.LastTextureHandle); + } + } + + private static ChargenAppearanceSelection DefaultSelection( + ChargenOptions options, uint heritageId, int genderKey) + { + Assert.True(options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)); + Assert.True(heritage!.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender)); + return ChargenAppearanceSelection.Default with + { + HairStyle = gender!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + } + + private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) => + (ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats)); + + private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter) + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: dats unavailable"); + dats = null; + adapter = null; + return false; + } + dats = new DatCollection(datDir, DatAccessType.Read); + adapter = new DatCollectionAdapter(dats); + return true; + } + + private sealed class FakeChargenRenderer : IChargenPreviewRenderer + { + public WorldEntity? LastEntity { get; private set; } + public int SetPreviewCallCount { get; private set; } + public int RenderCallCount { get; private set; } + + public void SetPreview(WorldEntity? entity) + { + LastEntity = entity; + SetPreviewCallCount++; + } + + public uint Render(int width, int height) + { + RenderCallCount++; + return 42u; + } + } + + private sealed class FakeChargenView : IChargenPreviewFrameView + { + public bool Visible { get; set; } = true; + public int Width { get; set; } = 128; + public int Height { get; set; } = 128; + public uint? LastTextureHandle { get; private set; } + + public bool TryGetVisibleSize(out int width, out int height) + { + width = Width; + height = Height; + return Visible; + } + + public void SetTextureHandle(uint textureHandle) => LastTextureHandle = textureHandle; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs index 9cd6158e..56ad43b5 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs @@ -11,10 +11,32 @@ namespace AcDream.App.Tests.Rendering; /// public sealed class ChargenPreviewRotationControllerTests { + /// + /// CC6b-MOUNT: retail's OPERATIVE starting heading is 180, not the ctor's + /// raw 0 — gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC sets + /// m_fCurHeading = 0f, but InitializePage @0x0047FDD0 always + /// runs immediately afterward (before the page is ever visible) and + /// overrides it to 180f at 0x00480235, pushed via + /// SetPlayerHeading at 0x0048023F. No player-visible chargen + /// Appearance frame is ever rendered at 0°. This is the seam a real mount + /// site experiences (the parameterless constructor), pinned here so a + /// future consumer can't silently regress to facing the character away + /// from the camera. See + /// for the full citation, including the cross-confirming + /// gmCGSummaryPage/gmBarberUI sibling call sites. + /// + [Fact] + public void DefaultConstructor_StartsAtRetailsOperative180DegreeHeading() + { + var controller = new ChargenPreviewRotationController(); + + Assert.Equal(180f, controller.HeadingDegrees); + } + [Fact] public void Toggle_StartsRotatingInTheGivenDirection() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); Assert.True(controller.IsRotating); @@ -24,7 +46,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Toggle_SameDirectionWhileRotating_Stops() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Toggle(ChargenRotateDirection.Clockwise); @@ -34,7 +56,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Toggle(ChargenRotateDirection.CounterClockwise); @@ -45,7 +67,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_WhileNotRotating_IsANoOp() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Tick(100.0); Assert.Equal(0f, controller.HeadingDegrees); @@ -57,7 +79,7 @@ public sealed class ChargenPreviewRotationControllerTests // Rotate() invalidates m_dLastRotateTime so the very first DoRotation // tick resets it to "now" rather than computing a huge jump from a // stale/never-set timestamp. - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(1000.0); @@ -71,8 +93,10 @@ public sealed class ChargenPreviewRotationControllerTests // Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid // guard, same as retail's own sentinel check would if Timer::cur_time // could ever read exactly zero — never in practice, so tests avoid - // it too). - var controller = new ChargenPreviewRotationController(); + // it too). Explicit 0f baseline keeps the relative-delta assertion + // below simple; the retail-default seam has its own dedicated test + // above. + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. controller.Tick(11.5); // half a revolution at 3 s/rev. @@ -83,7 +107,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.CounterClockwise); controller.Tick(10.0); controller.Tick(11.5); // would go to -180, wraps to +180. @@ -94,7 +118,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_AccumulatesAcrossMultipleTicks() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(10.0); controller.Tick(10.5); // +60 deg. @@ -113,7 +137,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_ClockwiseAdvancePast360_ClampsBackBySubtracting360() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. controller.Tick(10.0 + 3.5); // 3.5s at 3s/rev = 420 deg -> 420, clamped to 60. @@ -124,7 +148,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void ToOrientation_AtZeroHeading_IsIdentity() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); Quaternion orientation = controller.ToOrientation(); Assert.Equal(Quaternion.Identity.X, orientation.X, 4); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 9fd47f01..6fb39999 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Content; @@ -314,6 +315,131 @@ public sealed class CharacterCreationLiveDatTests } } + /// + /// Campaign CC slice CC6b-MOUNT — the Appearance page's full authored + /// widget catalog. Pins the campaign plan's risk item 4 finding (the + /// color-wheel/gradient family resolves through EXISTING + /// DatWidgetFactory mappings; no new widget type was needed — + /// see 's own class doc) + /// against the real installed DAT: gender/Face/Clothes buttons, all + /// nine spins, all nine color swatches, the shade scrollbar, and the + /// viewport. + /// + [InstalledDatFact] + public void AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + AssertButton(appearanceRoot, CharacterCreationAppearancePage.FemaleButtonId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.MaleButtonId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.FaceButtonId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.ClothesButtonId); + Assert.IsAssignableFrom( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.FaceChoicesId)); + Assert.IsAssignableFrom( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ClothesChoicesId)); + + foreach (uint spinId in new[] + { + CharacterCreationAppearancePage.HairSpinId, + CharacterCreationAppearancePage.EyesSpinId, + CharacterCreationAppearancePage.NoseSpinId, + CharacterCreationAppearancePage.MouthSpinId, + CharacterCreationAppearancePage.SkinSpinId, + CharacterCreationAppearancePage.HeadgearSpinId, + CharacterCreationAppearancePage.ShirtSpinId, + CharacterCreationAppearancePage.TrousersSpinId, + CharacterCreationAppearancePage.FootwearSpinId, + }) + { + AssertButton(appearanceRoot, spinId); + } + + // Every color-wheel-family id resolves through EXISTING + // DatWidgetFactory mappings (Button=1, Scrollbar=0xB, the generic + // Type-3 fallback) — the risk-item-4 scouting result, pinned. + foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) + AssertButton(appearanceRoot, swatchId); + Assert.IsType( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId)); + Assert.IsType( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId)); + + AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateClockwiseId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateCounterClockwiseId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomInId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomOutId); + + Assert.IsType( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ViewportId)); + } + + /// + /// Live-DAT-measured arrow geometry the page's spin OnClickAt zones are + /// built from — every one of the nine spins is uniformly 200px wide + /// with the two locally-reused arrow child ids + /// (0x1000030a decrement / 0x1000030b increment) at the + /// SAME local positions. If a future DAT revision changes this shared + /// template's geometry, this test (not a silent behavior change) is + /// where it shows up. + /// + [InstalledDatFact] + public void AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + ElementInfo? appearanceInfo = FindInfo( + rootInfo, CharacterCreationUiController.AppearancePageElementId); + Assert.NotNull(appearanceInfo); + + foreach (uint spinId in new[] + { + CharacterCreationAppearancePage.HairSpinId, + CharacterCreationAppearancePage.EyesSpinId, + CharacterCreationAppearancePage.NoseSpinId, + CharacterCreationAppearancePage.MouthSpinId, + CharacterCreationAppearancePage.SkinSpinId, + CharacterCreationAppearancePage.HeadgearSpinId, + CharacterCreationAppearancePage.ShirtSpinId, + CharacterCreationAppearancePage.TrousersSpinId, + CharacterCreationAppearancePage.FootwearSpinId, + }) + { + ElementInfo? spin = FindInfo(appearanceInfo!, spinId); + Assert.NotNull(spin); + Assert.Equal(200f, spin!.Width); + + ElementInfo? decrement = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Au); + ElementInfo? increment = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Bu); + Assert.NotNull(decrement); + Assert.NotNull(increment); + Assert.Equal(80f, decrement!.X); + Assert.Equal(127f, increment!.X); + } + } + + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (ElementInfo child in node.Children) + { + ElementInfo? found = FindInfo(child, id); + if (found is not null) return found; + } + return null; + } + private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host) { uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); @@ -331,6 +457,9 @@ public sealed class CharacterCreationLiveDatTests private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); + private static void AssertButton(UiElement root, uint elementId) => + Assert.IsType(UiElement.FindDescendant(root, elementId)); + private static ImportedLayout BuildSelected( IDatReaderWriter dats, uint layoutDid, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 2f2c201f..6f797a4a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -167,10 +167,12 @@ public sealed class CharacterCreationUiControllerTests } /// gmCGHeritagePage::ListenToElementMessage @ 0x00483860's - /// per-button SetHeritageGroup literal, plus CC4's interim - /// auto-gender-select seam (register AD-100). + /// per-button SetHeritageGroup literal. AD-101 RETIRED at CC6b-MOUNT: + /// a heritage click no longer auto-selects a gender — the Appearance + /// page's real gender buttons are the only gender-selection path now + /// (see ). [Fact] - public void HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender() + public void HeritageButton_SelectsHeritage_WithNoGenderSideEffect() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); @@ -178,7 +180,7 @@ public sealed class CharacterCreationUiControllerTests environment.Button(0x100003BFu).OnClick!(); // Aluvian Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage); - Assert.Equal(GenderKey, environment.Runtime.LastSelectedGender); + Assert.Equal(0u, environment.Runtime.LastSelectedGender); } /// gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi @@ -491,6 +493,251 @@ public sealed class CharacterCreationUiControllerTests Assert.Null(environment.Host.FixedCanvasSize); } + // ── Campaign CC slice CC6b-MOUNT: Appearance page ─────────────────── + + [Fact] + public void AppearanceGenderButton_SelectsGender() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + + environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!(); + Assert.Equal(1u, environment.Runtime.LastSelectedGender); + + environment.Button(CharacterCreationAppearancePage.FemaleButtonId).OnClick!(); + Assert.Equal(2u, environment.Runtime.LastSelectedGender); + } + + /// Spin arrow geometry (live-DAT-measured, see + /// 's own class doc): + /// x=[80,127) is the decrement child, x=[127,174) is the increment + /// child. + [Fact] + public void AppearanceSpin_IncrementZoneClick_CyclesStyleForwardFromUnset() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10); + + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + } + + [Fact] + public void AppearanceSpin_DecrementZoneClick_FromUnset_StartsAtStyleZero() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Non-headgear spins have no decomp-observable Unset-cycling case + // (retail always has a real 0-based index by the time the user can + // click — CycleIndex's own citation) — a first click from Unset in + // EITHER direction just starts cycling at style 0, not a ring. + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10); + + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + } + + /// CharGenState::SetHeadgearStyle's decomp-derived + /// (count+1)-position ring: incrementing from Unset lands on style 0; + /// decrementing FROM style 0 lands back on Unset. Headgear is the ONLY + /// spin with this ring — see 's + /// own citation. + [Fact] + public void AppearanceHeadgearSpin_RingIncludesTheUnsetPosition() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!(); + + environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(150, 10); // increment + Assert.Equal(ChargenAppearanceSlot.HeadgearStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + + environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(100, 10); // decrement + Assert.Equal(RuntimeCharacterCreationAppearance.Unset, environment.Runtime.LastAppearanceIndex); + } + + /// Pure wrap-semantics unit tests for + /// — the + /// decomp-derived arithmetic every spin's OnClickAt zone drives. + /// + [Theory] + [InlineData(0u, +1, 3, false, 1u)] + [InlineData(2u, +1, 3, false, 0u)] // plain wrap forward past the end. + [InlineData(1u, -1, 3, false, 0u)] + [InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start. + [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)] + [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 0u)] + [InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset. + [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0. + [InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset. + [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, true, 2u)] // headgear ring: Unset -> last. + public void CycleIndex_MatchesRetailsDecompDerivedWrap( + uint current, int delta, int count, bool allowUnset, uint expected) + { + Assert.Equal( + expected, + CharacterCreationAppearancePage.CycleIndex(current, delta, count, allowUnset)); + } + + /// Skin has no style index — retail disables its arrow + /// children (SetAttribute_Bool(...,0xd,1)). Every click on the skin + /// spin only selects it as the current part. + [Fact] + public void AppearanceSkinSpin_Click_NeverCallsSetAppearanceIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(100, 10); + environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(150, 10); + + Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount); + } + + /// gmCGAppearancePage::Update's heritage 6/0xc/0xd gate: the + /// Clothes sub-tab and the Nose/Mouth spins all hide. + [Fact] + public void OlthoiHeritage_HidesClothesButtonAndNoseMouthSpins() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(OlthoiId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + + Assert.False(environment.Button(CharacterCreationAppearancePage.ClothesButtonId).Visible); + Assert.False(environment.Button(CharacterCreationAppearancePage.NoseSpinId).Visible); + Assert.False(environment.Button(CharacterCreationAppearancePage.MouthSpinId).Visible); + } + + /// Fixture's Hair color list has 3 entries (indices 0-2) — a + /// swatch beyond that never reaches SetAppearanceIndex, matching + /// retail's own iNumColors > N gate. + [Fact] + public void AppearanceSwatch_WithinColorCount_SetsColorForTheCurrentPart() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Part defaults to Hair on construction — no extra click needed. + environment.Button(CharacterCreationAppearancePage.SwatchIds[1]).OnClick!(); + + Assert.Equal(ChargenAppearanceSlot.HairColor, environment.Runtime.LastAppearanceSlot); + Assert.Equal(1u, environment.Runtime.LastAppearanceIndex); + } + + [Fact] + public void AppearanceSwatch_BeyondColorCount_IsANoOp() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.Button(CharacterCreationAppearancePage.SwatchIds[^1]).OnClick!(); // index 8, count 3. + + Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount); + } + + [Fact] + public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.ShadeScroll().ScalarChanged!(0.75f); + + Assert.Equal(ChargenShadeSlot.Hair, environment.Runtime.LastShadeSlot); + Assert.Equal(0.75, environment.Runtime.LastShadeValue, 3); + } + + /// Nose/Mouth/Skin all route the shade scroll to SKIN shade — + /// SetShade's cases 2/3/4 share one body in the decompiled switch. + /// + [Fact] + public void AppearanceShadeScroll_ForNoseOrMouth_RoutesToSkinShade() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + // Select Nose as the current part: click its body (outside the + // arrow zones), which never changes the style index. + environment.Button(CharacterCreationAppearancePage.NoseSpinId).OnClickAt!(10, 10); + + environment.ShadeScroll().ScalarChanged!(0.5f); + + Assert.Equal(ChargenShadeSlot.Skin, environment.Runtime.LastShadeSlot); + } + + [Fact] + public void AppearanceZoomAndRotateButtons_DelegateToThePreviewControl() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + var preview = new FakeChargenPreviewControl(); + environment.Controller.AppearancePreviewControl = preview; + + environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.ZoomOutId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.RotateCounterClockwiseId).OnClick!(); + + Assert.Equal(1, preview.ZoomInCalls); + Assert.Equal(1, preview.ZoomOutCalls); + Assert.Equal(1, preview.RotateClockwiseCalls); + Assert.Equal(1, preview.RotateCounterClockwiseCalls); + } + + [Fact] + public void AppearanceZoomButtons_WithNoPreviewControlAssignedYet_AreHarmlessNoOps() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + // The page mounts before the graphics backend exists — every zoom/ + // rotate click before LivePresentationComposition assigns a real + // control must be a silent no-op, not a NullReferenceException. + environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); + } + + private static void SelectAluvianMale(EnvironmentHarness environment) + { + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!(); + } + + private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl + { + public int ZoomInCalls { get; private set; } + public int ZoomOutCalls { get; private set; } + public int RotateClockwiseCalls { get; private set; } + public int RotateCounterClockwiseCalls { get; private set; } + + public bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection) => true; + + public void ZoomIn() => ZoomInCalls++; + public void ZoomOut() => ZoomOutCalls++; + public void RotateClockwise() => RotateClockwiseCalls++; + public void RotateCounterClockwise() => RotateCounterClockwiseCalls++; + } + private static void BumpRevisionAndTick(EnvironmentHarness environment) { RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; @@ -552,6 +799,9 @@ public sealed class CharacterCreationUiControllerTests public UiTemplateListBox SkillsList() => Assert.IsType(Screen.FindElement(0x100003F7u)); + public UiScrollbar ShadeScroll() => + Assert.IsType(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId)); + /// Confirms or cancels the MOST RECENTLY opened confirmation /// dialog, using 's real /// button ids off the layout the factory's createLayout @@ -599,6 +849,8 @@ public sealed class CharacterCreationUiControllerTests SelectStartArea, _ => Result(RuntimeCommandStatus.Accepted), () => RequestExitCalls++, + SetAppearanceIndex: SetAppearanceIndex, + SetShade: SetShade, ResolveText: _ => null, OpenOnStart: false); } @@ -613,6 +865,11 @@ public sealed class CharacterCreationUiControllerTests public ChargenAttributeId LastAttributeSet { get; private set; } public int LastAttributeValue { get; private set; } public int LastSelectedStartArea { get; private set; } = -1; + public ChargenAppearanceSlot? LastAppearanceSlot { get; private set; } + public uint LastAppearanceIndex { get; private set; } + public int AppearanceIndexCallCount { get; private set; } + public ChargenShadeSlot? LastShadeSlot { get; private set; } + public double LastShadeValue { get; private set; } public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); @@ -662,6 +919,44 @@ public sealed class CharacterCreationUiControllerTests return Result(RuntimeCommandStatus.Accepted); } + private RuntimeCommandResult SetAppearanceIndex(ChargenAppearanceSlot slot, uint index) + { + LastAppearanceSlot = slot; + LastAppearanceIndex = index; + AppearanceIndexCallCount++; + View.Snapshot = View.Snapshot with { Appearance = WithAppearanceIndex(View.Snapshot.Appearance, slot, index) }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SetShade(ChargenShadeSlot slot, double value) + { + LastShadeSlot = slot; + LastShadeValue = value; + return Result(RuntimeCommandStatus.Accepted); + } + + private static RuntimeCharacterCreationAppearance WithAppearanceIndex( + RuntimeCharacterCreationAppearance a, + ChargenAppearanceSlot slot, + uint index) => slot switch + { + ChargenAppearanceSlot.EyesStrip => a with { EyesStrip = index }, + ChargenAppearanceSlot.NoseStrip => a with { NoseStrip = index }, + ChargenAppearanceSlot.MouthStrip => a with { MouthStrip = index }, + ChargenAppearanceSlot.HairStyle => a with { HairStyle = index }, + ChargenAppearanceSlot.HairColor => a with { HairColor = index }, + ChargenAppearanceSlot.EyeColor => a with { EyeColor = index }, + ChargenAppearanceSlot.HeadgearStyle => a with { HeadgearStyle = index }, + ChargenAppearanceSlot.HeadgearColor => a with { HeadgearColor = index }, + ChargenAppearanceSlot.ShirtStyle => a with { ShirtStyle = index }, + ChargenAppearanceSlot.ShirtColor => a with { ShirtColor = index }, + ChargenAppearanceSlot.TrousersStyle => a with { TrousersStyle = index }, + ChargenAppearanceSlot.TrousersColor => a with { TrousersColor = index }, + ChargenAppearanceSlot.FootwearStyle => a with { FootwearStyle = index }, + ChargenAppearanceSlot.FootwearColor => a with { FootwearColor = index }, + _ => a, + }; + private static RuntimeCommandResult Result(RuntimeCommandStatus status) => new(status, Generation); @@ -680,17 +975,34 @@ public sealed class CharacterCreationUiControllerTests MotionTableId: 0u, CombatTableId: 0u, BaseObjDesc: ChargenObjDesc.Empty, - HairColors: [], - HairStyles: [], - EyeColors: [], - EyeStrips: [], - NoseStrips: [], - MouthStrips: [], - Headgears: [], - Shirts: [], - Pants: [], - Footwear: [], - ClothingColors: []); + // Campaign CC slice CC6b-MOUNT: non-empty appearance lists + // so the Appearance page's spin-cycle/wrap and swatch/shade + // dispatch tests have real option counts to exercise (the + // CC4 fixture left these empty since no page read them yet). + HairColors: [0x1000u, 0x1001u, 0x1002u], + HairStyles: + [ + new ChargenHairStyle(IconId: 1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + new ChargenHairStyle(IconId: 2u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + new ChargenHairStyle(IconId: 3u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + ], + EyeColors: [0x2000u, 0x2001u], + EyeStrips: + [ + new ChargenEyeStrip(IconId: 1u, BaldIconId: 1u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty), + new ChargenEyeStrip(IconId: 2u, BaldIconId: 2u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty), + ], + NoseStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)], + MouthStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)], + Headgears: + [ + new ChargenGearOption("Cloth Cap", ClothingTableId: 1u, WeenieDefaultId: 1u), + new ChargenGearOption("Leather Cap", ClothingTableId: 2u, WeenieDefaultId: 2u), + ], + Shirts: [new ChargenGearOption("Tunic", ClothingTableId: 3u, WeenieDefaultId: 3u)], + Pants: [new ChargenGearOption("Trousers", ClothingTableId: 4u, WeenieDefaultId: 4u)], + Footwear: [new ChargenGearOption("Boots", ClothingTableId: 5u, WeenieDefaultId: 5u)], + ClothingColors: [0x3000u, 0x3001u, 0x3002u]); var templates = new List { @@ -841,7 +1153,7 @@ public sealed class CharacterCreationUiControllerTests root.Children.Add(BuildHeritagePage()); root.Children.Add(BuildProfessionPage()); root.Children.Add(BuildSkillsPage()); - root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId)); + root.Children.Add(BuildAppearancePage()); root.Children.Add(BuildTownPage()); root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); @@ -947,6 +1259,82 @@ public sealed class CharacterCreationUiControllerTests return page; } + /// Campaign CC slice CC6b-MOUNT: the Appearance page fixture. + /// Spin geometry (each 200px wide, arrow children at the live-DAT- + /// measured x=80/127 — see 's + /// own doc comment) mirrors the real installed layout exactly so the + /// same OnClickAt zone math this page uses in production is what these + /// tests exercise. + private static ElementInfo BuildAppearancePage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.AppearancePageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FemaleButtonId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.MaleButtonId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FaceButtonId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ClothesButtonId)); + page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.FaceChoicesId)); + page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.ClothesChoicesId)); + + foreach (uint spinId in new[] + { + CharacterCreationAppearancePage.HairSpinId, + CharacterCreationAppearancePage.EyesSpinId, + CharacterCreationAppearancePage.NoseSpinId, + CharacterCreationAppearancePage.MouthSpinId, + CharacterCreationAppearancePage.SkinSpinId, + CharacterCreationAppearancePage.HeadgearSpinId, + CharacterCreationAppearancePage.ShirtSpinId, + CharacterCreationAppearancePage.TrousersSpinId, + CharacterCreationAppearancePage.FootwearSpinId, + }) + { + page.Children.Add(SpinInfo(spinId)); + } + + foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) + page.Children.Add(ButtonInfo(swatchId)); + + page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId)); + page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId)); + + var viewport = new ElementInfo + { + Id = CharacterCreationAppearancePage.ViewportId, + Type = 0xDu, + Width = 300f, + Height = 300f, + }; + page.Children.Add(viewport); + + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); + + return page; + } + + private static ElementInfo SpinInfo(uint id) + { + var spin = new ElementInfo + { + Id = id, + Type = 1u, + Width = 200f, + Height = 24f, + }; + spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f }); + spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f }); + return spin; + } + private static UiElement BuildSkillRowTemplate(uint templateElementId) => LayoutImporter.Build( new ElementInfo From d2a71152d23dbdd0cf31d6566b3a1e2b7820d35d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 22:19:02 +0200 Subject: [PATCH 097/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC6b-MO?= =?UTF-8?q?UNT=20review=20fix=20round=20=E2=80=94=20F1-F13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes every finding from the dual-lens review of 34c6fceab0 (architectural PASS-with-items, retail-fidelity FAIL). Re-derived every decomp citation against docs/research/named-retail/acclient_2013_pseudo_c.txt directly rather than trusting the reviewer's transcription. Wrap/normalize semantics (F1): CycleIndex's decrement-from-Unset landed on 0; the decomp's shared decrement tail (label_47f065/label_47f6d9, the same switch the headgear ring was ported from) computes new=cur-1=-2 on the raw signed int32, which wraps to count-1 — matching headgear's own ring shape. Also ports the spin body-click normalize-and-write-back retail's cases 0xa5-0xae all share (NormalizeChoiceOnSelect), which acdream had dropped entirely. Flips the one test that pinned the wrong expectation and adds select-zone coverage no prior test isolated. Heritage gate (F3): Update's Gearknight/Olthoi/OlthoiAcid branches reset SetChoice(FACE)/SetSelection(HAIR) unconditionally, not only when Clothes was showing — a conditional gate stranded Nose/Mouth as the current part under a Face-tab session. Doc corrections propagated everywhere they repeated (F4, F5, F6, plus the plan doc's own CC6b-MOUNT ledger row for F1/F3): the gmBarberUI heading citation conflated PostInit with InitializePage; Random's Appearance disable was mislabeled a placeholder when it's really AP-212's unported RandomizeAppearance/RandomizeClothing gap; the master-page doc still called the Appearance page content-inert after this campaign made it real. Visual substitutions widened (F2): AP-215 named only two of the Appearance page's swatch/spin substitutions. Ports the two cheap ones directly — current-part highlight via SetSelection's SetState(1)/SetState(6), routed through the existing UiButtonStateMachine.Normal/Highlight ids and IUiDatStateful.TrySetRetailState seam (installed-DAT-confirmed ToggleBehavior=true on all nine spins); the shade scrollbar's SetVisible(0) for Eyes vs acdream's Enabled=false. Files the other five (DoColorSpots, the inert GradCircle, spin-caption/heritage-caption loss, the Skin-spin MoveTo reposition, the Gearknight-boundary randomize calls) as new register rows AP-216..AP-220 and corrects the plan doc's false claim that AP-215 already named the GradCircle. Unlocked DAT read (F7, BLOCKER): ChargenPreviewController.Rebuild called ChargenAppearanceFactory.TryCompose outside _datLock while the very next line correctly locked TryBuildAnimated — CC6a's own F4 class of bug, reintroduced at this catalog's first production call site. Wrapped in the same lock; documented the invariant on ChargenAppearanceCatalog itself. One-shot preview mount (F8): LivePresentationComposition reads ChargenPreviewViewportWidget once, but its underlying mount (CharacterCreationUiMountCoordinator) is explicitly retryable while this GPU-resource composition pass is not — unlike PaperdollViewportWidget, which IS eager/non-retryable, so the "mirrors Paperdoll" doc claim was false. Retrofitting cross-frame retry here would mean restructuring this composition's one-shot contract for every private viewport (paperdoll, creature appraisal) and FrameRootComposition's fixed frame-group array — out of this round's blast radius. Corrected the doc and made the failure loud (a diagnostic log) instead of silent. Dispose leak (F9): ChargenPreviewController.Dispose left the preview WorldEntity referenced by the leased renderer until the renderer's own, later disposal. Releases it on its own teardown now. Test-quality items (F10, F11, F13): pinned the spin arrow widths (47px, both arrows) the 174 zone boundary is derived from, plus a controller test for the previously-uncovered select zone. Measured the shade scrollbar's authored orientation instead of assuming it — it is VERTICAL (33x85) — which is a real production bug: UiScrollbar only routed scalar-mode mouse events when Horizontal was true, so the shade control never fired in production. Added OnVerticalScalarEvent/DrawVerticalScalar mirroring the existing horizontal scalar path. Converted ChargenPreviewControllerTests from silent-pass [Fact] to the shared InstalledDatFactAttribute skip-reporting pattern. Adjudication (F12): AD-101's retirement leaves TryBeginFinish's four local refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) with no heritage/gender gate — currently latent since Finish stays hard-disabled this round. Amended the campaign plan's CC5 slice scope to require BOTH a heritage/gender refusal AND a real RandomizeCharacter port before the connected user gate opens Finish; noted the interaction on AP-214's own register row. No CC5 implementation in this commit. Gates: dotnet build -c Release green across the full solution. App suite (Release, ACDREAM_PROBE_LIVE_MOUNT=1) 5223/3 skips, Runtime suite 1713/0 — both clean across repeated runs. A full-solution run surfaced three pre-existing, previously-documented flakes unrelated to this change (Streaming.LandblockBuildFactoryTests/LandblockPresentationPipelineTests #402, Core.Net.Tests.NakEmissionTests loss soak) — each confirmed passing in isolation, consistent with their known full-suite-parallelism-timing history; none touch any file this commit changes. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 11 +- .../2026-08-15-character-creation-campaign.md | 4 +- .../LivePresentationComposition.cs | 37 ++++ .../Rendering/ChargenPreviewController.cs | 27 ++- .../ChargenPreviewRotationController.cs | 16 +- .../Layout/CharacterCreationAppearancePage.cs | 164 ++++++++++++++++-- .../Layout/CharacterCreationUiController.cs | 20 ++- src/AcDream.App/UI/RetailUiRuntime.cs | 26 ++- src/AcDream.App/UI/UiScrollbar.cs | 114 +++++++++++- .../CharGen/ChargenAppearanceCatalog.cs | 15 ++ .../ChargenPreviewControllerTests.cs | 33 +++- .../Layout/CharacterCreationLiveDatTests.cs | 37 +++- .../CharacterCreationUiControllerTests.cs | 84 ++++++++- .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 37 ++++ 14 files changed, 571 insertions(+), 54 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 20985013..940fff66 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 151 active rows (AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 156 active rows (AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -390,9 +390,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions).** Two narrow, DECIDED substitutions where acdream reaches the same functional selection through a different widget mechanism than retail's own: (1) the nine color swatches (`0x1000030f-0x10000317`) use their own `UiButton.Selected` highlight state for "this is the current color" instead of toggling the separate Type-3 companion overlay element (`0x10000318-0x10000320`) retail's `SetColor @ 0x0047DD50` shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — the composited pixel result is UNVERIFIED to match, not asserted identical (same "measured, not assumed" discipline AD-103's own F5 note established for a different swallowed-child case). (2) the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons is out of this round's scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop; `SetStyleSpinLabel`) | Both substitutions reach the SAME underlying selection (the swatch highlight still shows which color index is active; the ordinal still lets a player cycle deterministically and see which slot they're on) through existing widget primitives (`UiButton.Selected`, `UiButton.Label`) rather than adding new rendering infrastructure (a second overlay-visibility channel, or an icon-texture pipeline) this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a different (simpler) selected-swatch visual and text labels where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close the label half of this row. | `gmCGAppearancePage::SetColor @0x0047DD50` (the `m_tColorWheel` overlay toggle); `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | -| AP-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | +| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1).** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. acdream's swatches show only their authored (static) DAT art regardless of which color they represent or whether the current part even has that many colors — AP-215's `.Selected` substitution covers WHICH swatch is chosen, not what each swatch itself looks like. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — sets `.Selected` only, never touches swatch appearance) | The nine swatches already reach the correct SELECTION semantics through `DatWidgetFactory`'s existing `UiButton` primitive; painting each swatch with a computed color needs either a per-swatch dynamic-color render path (new UI infrastructure this scope doesn't otherwise need) or a fallback to static art, which is what this round shipped. | A side-by-side against retail shows every swatch drawing the SAME authored art regardless of which color it represents, and swatches beyond a part's real color count staying visibly "on" instead of blanking — a real visual gap on a screen the player stares at while picking a color, not a selection-correctness gap. | `gmCGAppearancePage::DoColorSpots @0x0047d850` | +| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4) — corrects the campaign plan's own CC6b-MOUNT ledger row, which wrongly claimed AP-215 already named the GradCircle.** Retail's `gmCGAppearancePage::DoGradDisk @0x0047da90` drives the GradCircle (`0x1000030e`) as an interactive hue/gradient picker, click-mapped to a color. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback (the risk-item-4 color-wheel scouting result) with no click handling wired to it at all — it is purely decorative in this round. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page's own constructor never binds a handler to it) | The nine swatch buttons already provide a full, decomp-cited color-selection path (`SetColor`'s own cases `5`-`0xd`); the GradCircle's own click-to-color-position mapping has no decomp citation yet in this campaign's research. | A user clicking the GradCircle in acdream gets no response at all, where retail would change the current part's color — a dead-control gap a visual gate would surface immediately. | `gmCGAppearancePage::DoGradDisk @0x0047da90` | +| AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | +| AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | +| AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7).** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for something else (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6`). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | +| AP-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). **Latent Finish-path interaction noted at the CC6b-MOUNT review fix round (F12, 2026-08-15):** with AD-101 retired, honest-blank heritage/gender means `RuntimeCharacterCreationState.TryBeginFinish` can be reached with `_genderKey == 0` (or an unselected heritage) — `TryBeginFinish`'s four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no heritage/gender gate today. Currently LATENT ONLY (Finish is hard-disabled + `OnClick` null this round — TS-82); CC5's own scope is now AMENDED (see the plan doc's Slices table) to land BOTH a heritage/gender refusal in `TryBeginFinish` AND a real `RandomizeCharacter` port before the connected user gate opens Finish for real use, since a gate alone does not reproduce retail's actual guarantee (retail's ctor-time `RandomizeCharacter` means heritage/gender are NEVER unset by the time a player can reach Finish at all). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | -| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (this round's placeholder), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | +| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (CC6b-MOUNT review fix F5 correction: NOT a placeholder — retail's own `DoRandom` case 3 fully enables Random here; the disable rests on the same unported `RandomizeAppearance`/`RandomizeClothing` primitives this row already names), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | ## 4. Temporary stopgap (TS) — 50 active rows (TS-82 filed 2026-08-15 at Campaign CC slice CC4 — the Appearance/Summary page roots mount empty and content-inert, reachable via free tab navigation, pending CC5/CC6a/CC6b; TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 5352207f..f7fa2011 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -165,7 +165,7 @@ human-form camera offsets). | CC2 | Wire: `CharacterCreate` 0xF656 builder (byte-exact incl. checksum), shared verification-response type (refactor from `CharacterRestore`), WorldSession request-correlation for 0xF643, send seam, status events + contract/tailer update | — | | CC3 | `RuntimeCharacterCreationState`: full CharGenState mirror, per-page commands, retail client gates (full-spend, name, 55-slot invariant, client-side slot cap), verification latch, Ok → roster append + retail log-straight-in | CC1, CC2 | | CC4 | Screen shell + form pages (App): mount (enum 0x10000039), master nav/tabs/progress, dialogs, Heritage + Profession + Skills + Town pages | CC1, CC3 | -| CC5 | Summary page: name input (NameInputFilter, `ID_CharGen_NameTooLong`), summary listbox, static summary viewport, Finish gates + full response/dialog handling | CC3, CC4 | +| CC5 | Summary page: name input (NameInputFilter, `ID_CharGen_NameTooLong`), summary listbox, static summary viewport, Finish gates + full response/dialog handling. **CC6b-MOUNT review fix round F12 amendment (2026-08-15):** Finish gates MUST add a heritage/gender refusal to `RuntimeCharacterCreationState.TryBeginFinish` — with AD-101 retired, a caller can hold `_genderKey == 0` (or, before a real heritage/gender selection, `_heritageId == 0`) all the way to Finish, and `TryBeginFinish`'s current four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no gate for either — see AP-214's own noted latent-interaction risk. This slice MUST ALSO land a real `RandomizeCharacter` port (the shared AP-214/AP-212 primitive gap) BEFORE the connected user gate opens Finish for real use — the reviewer's requirement, not optional polish: retail's `gmCharGenMainUI` ctor rolls a full character before any page constructs (AP-214), so a heritage/gender check alone does not reproduce retail's actual guarantee that Finish is never reachable with an unset heritage/gender; only porting `RandomizeCharacter` closes that gap the way retail's own architecture does. | CC3, CC4 | | CC6 | Appearance page + preview: index→ObjDesc factory, chargen preview renderer (offscreen, heading camera, rotate/zoom buttons), spin controls + color wheels; **staged:** CC6a static-pose preview (paperdoll-style held frame, register row for the missing idle loop), CC6b idle animation + zoom rest-freeze (retire the row) | CC1, CC4 | | CC7 | End-to-end: Create button un-ghosts, full flow vs ACE shapes in tests, launcher payload cycle, connected checklist doc | all | @@ -258,4 +258,4 @@ the user gate. **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | -| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice) | (this commit) | OWED (dual-lens review pending — Sonnet-implementation session only) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. Non-headgear spins have no decomp-observable Unset-starting-point case (retail always has a real index by the time the user can click — see AP-214) so a first click from Unset in EITHER direction starts at style 0 (a documented, non-retail-cited edge-case default, not a guess dressed as a citation). **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); forces `SetChoice(FACE)` if Clothes was showing when the gate engages. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins and the GradCircle's own interactive click-to-hue behavior (AP-215 both name this — the GradCircle is currently a non-interactive static container this round, since its own click-to-color-position mapping has no decomp citation yet and the nine swatch buttons already provide a full, decomp-cited color-selection path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it. | +| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, (this commit — the review fix round) | fix round landed F1-F13, narrow re-review pending | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own interactive click-to-hue behavior (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217 — the GradCircle is currently a non-interactive static container this round, since its own click-to-color-position mapping has no decomp citation yet and the nine swatch buttons already provide a full, decomp-cited color-selection path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it. | diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 92f2df7b..c9c4d8db 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -997,6 +997,31 @@ internal sealed class LivePresentationCompositionPhase // Same "both arms exist, needs a dispatcher + the retained-UI // viewport widget" shape as paperdoll/creature-appraisal above — // this is the THIRD private creature viewport, not a new pattern. + // + // Fix round F8 disposition: unlike paperdoll's PaperdollViewportWidget + // (an eager, non-retryable auto-property — see that property's own + // corrected doc comment), ChargenPreviewViewportWidget is + // computed-through a coordinator (CharacterCreationUiMountCoordinator) + // that IS explicitly retryable/idempotent across frames. This + // composition pass itself runs EXACTLY ONCE, synchronously, inside + // GameWindow.OnLoad — if the coordinator's mount hasn't succeeded + // yet at this exact instant, this block is skipped and NEVER + // retried; the coordinator's own later per-frame retries (driven + // from RetailUiRuntime.Tick) can still complete the CONTROLLER mount + // afterward, but this GPU-side renderer/viewport binding will not + // pick that up. DECIDED at the review: this composition pass is a + // one-shot GPU-resource wiring step (matching paperdoll's and + // creature-appraisal's own one-shot binding in this exact method, + // and PublishLivePresentation's own "set exactly once" invariant a + // few hundred lines below) — retrofitting cross-frame retry here + // would mean restructuring this whole composition's one-shot + // contract (and the fixed PrivateEntityViewportFrameGroup array + // FrameRootComposition builds from its result) for every private + // viewport, not just this one; that is out of this fix round's + // blast radius. What changes here instead: a loud diagnostic + // instead of a silent skip, so an operator can SEE the preview + // failed to bind this session rather than the symptom (dead + // zoom/rotate buttons) reading as unexplained. CompositionAcquisitionScope.CompositionAcquisitionLease< ChargenPreviewRenderer>? chargenPreviewLease = null; ChargenPreviewController? chargenPreviewController = null; @@ -1053,6 +1078,18 @@ internal sealed class LivePresentationCompositionPhase } }); } + else if (dispatcherLease.Resource is not null) + { + // Fix round F8: dispatcher is available but the mount coordinator + // hadn't resolved ChargenPreviewViewportWidget by this one-shot + // pass — loud instead of silent, since the coordinator's own + // later per-frame retries cannot recover this GPU-side binding + // (see this block's own disposition comment above). + Console.WriteLine( + "[UI] chargen preview viewport unavailable at composition " + + "time — the Appearance page's zoom/rotate controls and " + + "3D preview will not function this session."); + } Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index 91c1e669..94998b47 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -225,9 +225,24 @@ internal sealed class ChargenPreviewController : return true; } - if (!ChargenAppearanceFactory.TryCompose( + // Fix round F7 (BLOCKER, CC6a's own F4 re-introduced at a new site): + // TryCompose reaches ChargenAppearanceCatalog.TryGetPalSet/ + // TryGetClothingTable (_palSets/_clothingTables), which do lazy raw + // DatCollection.Get() reads on first use — DatCollection is NOT + // thread-safe (feedback_phase_a1_hotfix_saga.md), and this UI-thread + // Rebuild call is the catalog's first production call site. Every + // sibling DAT read in this same method already guards with + // _datLock (see the TryBuildAnimated call just below) — this one + // must too. + bool composed; + ChargenAppearanceResult result; + lock (_datLock) + { + composed = ChargenAppearanceFactory.TryCompose( options, heritageId, genderKey, selection, - _palSets, _clothingTables, out ChargenAppearanceResult result)) + _palSets, _clothingTables, out result); + } + if (!composed) { return false; } @@ -291,6 +306,14 @@ internal sealed class ChargenPreviewController : if (_disposed) return; _disposed = true; + // Fix round F9: release the preview entity NOW rather than leaving + // the leased renderer holding it until the renderer's OWN disposal + // (a separate manifest entry, one step later) — this class built + // the entity via Rebuild, so it releases it on its own teardown + // instead of relying on a downstream owner to notice. + _renderer.SetPreview(null); + _animator = null; + _zoom = null; // The renderer itself is a leased composition resource disposed by // the composition root (mirrors PaperdollViewportRenderer — this // class does not own its lifetime, only its per-frame drive). diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs index 265c5da4..3e7fa9e9 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -59,10 +59,18 @@ internal sealed class ChargenPreviewRotationController /// user actually sees. The same override, independently, is what every /// other gmCG3DView owner does for ITS own instance: /// gmCGSummaryPage::InitializePage @0x0047BD54 (a separate - /// viewport/page, CC5's scope, not this one) and - /// gmBarberUI::PostInit (~0x004DE330, pushed at - /// 0x004E03B5) both call the identical - /// SetPlayerHeading(m_p3DView, 180f) for their own pages. Since + /// viewport/page, CC5's scope, not this one) and gmBarberUI + /// corroborate 180 TWICE, in two separate functions (fix round F4 + /// correction — the original citation here wrongly attributed both + /// writes to PostInit): gmBarberUI::PostInit @0x004de2e0 + /// has its OWN m_fCurHeading = 180f write at 0x004de330 + /// (no push there — PostInit ends right after that assignment); + /// separately, gmBarberUI::InitializePage @0x004e0040 has its OWN + /// redundant m_fCurHeading = 180f write at 0x004e03ab, + /// THEN pushes it via SetPlayerHeading(m_p3DView, 180f) at + /// 0x004e03b5 — the address the original citation attributed to + /// PostInit. Two functions, both landing on 180, not one + /// function pushing from the other's write. Since /// this controller — like retail's m_fCurHeading — is itself the /// PAGE-level heading owner (not the view's), matching the value every /// real page converges on before its first frame is the retail-faithful diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 82b4fe60..ec3907be 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -257,10 +257,19 @@ internal sealed class CharacterCreationAppearancePage : IDisposable if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin)) mouthSpin.Visible = !clothesHidden; _eyesArrowsDisabled = clothesHidden; - if (clothesHidden && _currentChoice == Choice.Clothes) + if (clothesHidden) { - // Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes - // unreachable so the page never gets stuck showing a hidden tab. + // Fix round F3: retail's Gearknight branch + // (@0x0047eac6/0x0047eacf) and Olthoi/OlthoiAcid branch + // (@0x0047ee32/0x0047ee3b) both call SetChoice(ECG_CHOICE_FACE) + // + SetSelection(ECG_PARTS_HAIR) UNCONDITIONALLY — every single + // time Update runs while the heritage hides Clothes, not only + // when the Clothes tab happened to be showing. A conditional + // gate here (checking _currentChoice == Choice.Clothes) missed + // the case where _currentPart was Nose or Mouth — both ALSO + // hidden by this same branch — while _currentChoice was still + // Face: acdream would leave the hidden Nose/Mouth part driving + // the shade control; retail always snaps back to Hair. _currentChoice = Choice.Face; _currentPart = Part.Hair; } @@ -334,10 +343,87 @@ internal sealed class CharacterCreationAppearancePage : IDisposable { if (_disposed) return; + NormalizeChoiceOnSelect(part); _currentPart = part; RefreshColorAndShadeControlsFromLatestSnapshot(); } + /// + /// Fix round F1: ports retail's spin BODY-click normalize-and-write-back + /// — gmCGAppearancePage::ListenToElementMessage cases 0xa5- + /// 0xa9 (hair/eyes/nose/mouth/skin, @0x0047f04b-0x0047f1bf) + /// and 0xab-0xae (headgear/shirt/trousers/footwear, + /// @0x0047f212-0x0047f3ac) each re-clamp the part's current index + /// into [0, count) BEFORE selecting it as current, not just read + /// it. Retail's rule (Hair's case 0xa5 is representative, + /// @0x0047f051-0x0047f081 plus the shared tail at + /// label_47f065/label_47f6d9): cur >= count -> 0; + /// cur < 0 -> count-1. Headgear's own case (0xab, + /// @0x0047f218-0x0047f23e) excludes its 0xffffffff Unset + /// sentinel from the "cur < 0" branch + /// (iCurrentChoice < 0 && iCurrentChoice != 0xffffffff), + /// so an Unset headgear survives a body click untouched; every other + /// indexed spin has no such exclusion, so an Unset (AP-214 honest-blank) + /// style wraps to count-1 on the FIRST body click — the same + /// count-1 wrap 's own decrement-from-Unset fix + /// (F1's sibling finding) applies. Skin (case 0xa9, + /// @0x0047f1bf-0x0047f1fb) normalizes its local cache too but + /// never writes back (no CharGenState field for Skin — acdream: + /// no case), matching this method's no-op + /// early return for it. In acdream there is no separate UI-local cache + /// to desync from the persisted index (unlike retail's m_tChoices) + /// — + /// already rejects any out-of-range write and + /// ConstrainAppearanceByGenderLocked already clamps on every + /// gender change — so the ONLY reachable out-of-range case here is + /// Unset itself; the >=count branch is kept for completeness/fidelity + /// with retail's own defensive shape, not because acdream can hit it. + /// + private void NormalizeChoiceOnSelect(Part part) + { + ChargenAppearanceSlot? slot = StyleSlotFor(part); + if (slot is null) + return; // Skin: retail normalizes locally but never writes back. + + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + + int count = StyleCount(part, gender); + if (count <= 0) + return; + uint current = StyleCurrent(part, snapshot.Appearance); + + uint normalized; + if (part == Part.Headgear) + { + // 0x0047f218/0x0047f226: cur >= count -> Unset; Unset itself + // (cur < 0 as signed int32) is explicitly excluded from the + // "cur < 0 -> count-1" branch, so it stays Unset. + if (current != Unset && current >= (uint)count) + normalized = Unset; + else + return; + } + else + { + // 0x0047f04b family: cur >= count -> 0; cur < 0 -> count-1. + // Unset (0xFFFFFFFF) reads as -1 in retail's signed int32 store, + // so it takes the "cur < 0" branch same as any other negative. + if (current != Unset && current >= (uint)count) + normalized = 0u; + else if (current == Unset) + normalized = (uint)(count - 1); + else + return; + } + + _bindings.SetAppearanceIndex?.Invoke(slot.Value, normalized); + } + private void CycleStyle(Part part, int delta) { if (_disposed) @@ -378,14 +464,29 @@ internal sealed class CharacterCreationAppearancePage : IDisposable /// +1 positions (every real index, plus /// — decrementing from index 0 lands on Unset, /// incrementing from Unset lands on index 0, matching - /// ListenToElementMessage's cases 6 exactly). Every other - /// style spin has no decomp-observable Unset-cycling case (retail always - /// has a real 0-based index by the time the user can click — see - /// AP-214's RandomizeCharacter-at-open finding, which acdream - /// does not port this round) — an Unset start there is an edge case - /// retail itself never reaches, so the first click either direction just - /// starts cycling from index 0 rather than reconstructing an unfounded - /// wrap direction. + /// ListenToElementMessage's cases 6 exactly). + /// + /// + /// Fix round F1: every OTHER style spin ALSO has a decomp- + /// observable Unset-cycling case — it lives in the same switch the + /// headgear ring was ported from, at the shared decrement tail + /// (label_47f065/label_47f6d9, reached from Hair's + /// decrement case @0x0047f465-0x0047f486 and, inlined per-part, + /// from Eyes/Nose/Mouth/Shirt/Trousers/Footwear's own decrement cases + /// @0x0047f491-0x0047f65c): decrementing FROM Unset + /// (cur=-1 as signed int32) computes new = cur - 1 = -2, + /// which is < 0, so it wraps to count - 1 — the SAME + /// "wrap to the last index" shape headgear's own ring uses, just without + /// headgear's extra Unset ring position. Incrementing FROM Unset + /// computes new = -1 + 1 = 0, which is already in + /// [0, count), so it lands on style 0 — this half was already + /// correct. The prior doc here claimed "no decomp-observable + /// Unset-cycling case" and picked index 0 for BOTH directions; the + /// decomp refutes that for decrement. This matters in practice: AP-214's + /// honest-blank open leaves every non-headgear index Unset, so the + /// FIRST left-arrow click a user makes on this page hits this exact + /// path. + /// /// internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset) { @@ -401,7 +502,22 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } if (current == Unset) - return 0u; + { + // Retail's per-part decrement/increment cases each recompute + // `new = cur + delta` on the RAW signed int32 (Unset = -1) and + // apply a SINGLE-STEP clamp (not a full modulo): new < 0 wraps + // to count-1, new >= count wraps to 0. Since every real caller + // only ever passes delta = -1/+1 here, evaluating that one-step + // clamp directly (rather than routing Unset through the general + // Mod() below, which assumes a valid starting index) reproduces + // retail exactly for both directions. + int fromUnset = -1 + delta; + if (fromUnset < 0) + return (uint)(count - 1); + if (fromUnset >= count) + return 0u; + return (uint)fromUnset; + } return (uint)Mod((int)current + delta, count); } @@ -455,6 +571,23 @@ internal sealed class CharacterCreationAppearancePage : IDisposable IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) { + // Fix round F2 item 2: gmCGAppearancePage::SetSelection + // @0x0047e260 resets the PREVIOUS current-part spin to state 1 + // (@0x0047e306, this->m_pCurSelection->vtable->SetState(1)) and sets + // the NEW one to state 6 (@0x0047e837, + // this->m_pCurSelection->vtable->SetState(6)) — a literal highlight + // toggle. UiButtonStateMachine.Normal/Highlight are already retail's + // own numeric ids 1/6 (see that class); IUiDatStateful.TrySetRetailState + // is the established seam for pushing a raw retail state id + // (CharacterCreationUiController.SetMasterPageState's own pattern). + foreach ((Part spinPart, UiButton spin) in _spins) + { + spin.TrySetRetailState( + spinPart == _currentPart + ? UiButtonStateMachine.Highlight + : UiButtonStateMachine.Normal); + } + ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart); uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); for (int i = 0; i < _swatches.Length; i++) @@ -466,7 +599,12 @@ internal sealed class CharacterCreationAppearancePage : IDisposable ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); if (_shadeScroll is null) return; - _shadeScroll.Enabled = shadeSlot is not null; + // Fix round F2 item 3: gmCGAppearancePage::SetSelection HIDES the + // shade scrollbar for Eyes (@0x0047e862, SetVisible(0) — Eyes has no + // shade case in SetShade at all) and shows it otherwise + // (@0x0047e878, SetVisible(1)) — retail never DISABLES it, it + // removes it from the layout entirely. + _shadeScroll.Visible = shadeSlot is not null; if (shadeSlot is { } slot) { double shade = ShadeCurrent(slot, snapshot.Appearance); diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index 7e082c36..36126958 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -50,9 +50,11 @@ public sealed record CharacterCreationRuntimeBindings( /// through retail gmCharGenMainUI's authored retained layout — the /// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/ /// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this -/// slice builds. The Appearance (0x100003d4) and Summary -/// (0x100003d6) page roots are mounted but content-inert — CC6/CC5 -/// fill them (register TS-82). +/// slice builds. Fix round F6: the Appearance (0x100003d4) page root +/// is fully LIVE as of CC6b-MOUNT (); +/// only the Summary (0x100003d6) page root remains mounted but +/// content-inert — CC5 fills it (register TS-82, narrowed to Summary-only +/// at CC6b-MOUNT). /// /// /// Decomp anchors: root construction + child resolution @@ -639,10 +641,14 @@ internal sealed class CharacterCreationUiController : IDisposable break; } - // Random (0x100003cb): retail refuses on Skills (no - // RandomizeSkills primitive ported — AP-212) and on Summary - // (MakeRandomizeWarningDialog is CC5's); Appearance is this round's - // placeholder. + // Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70 + // case 3 fully ENABLES Random on Appearance (RandomizeClothing when + // m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this + // is NOT a placeholder gap the way the old comment claimed. The + // disable here rests on the SAME unported-primitive gap AP-212 + // tracks for Skills (no RandomizeSkills) and Summary (no + // RandomizeCharacter) — RandomizeAppearance/RandomizeClothing are + // two more of AP-212's six named-but-unported primitives. _random.Enabled = _currentPage is not (Page.Skills or Page.Appearance or Page.Summary); // Finish stays ghosted regardless of page — Summary is a diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 145f168b..dfaab47c 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -624,8 +624,30 @@ public sealed class RetailUiRuntime : IDisposable /// Campaign CC slice CC6b-MOUNT: the Appearance page's authored /// viewport (0x100003bb) — null until the screen has mounted. - /// Mirrors 's own computed-through - /// shape. + /// + /// + /// Fix round F8 correction: this is NOT the same shape as + /// — that one is a plain + /// { get; private set; } auto-property assigned exactly once, + /// eagerly and non-retryably, inside MountInventory() (itself + /// called synchronously from Initialize(); if it fails the whole + /// call throws and the WHOLE UI runtime fails to + /// construct — there is no partial-failure case where + /// stays null while the rest of + /// the runtime comes up). THIS property is computed-through specifically + /// BECAUSE its underlying mount, _characterCreationMount + /// (), is explicitly + /// retryable/idempotent — ticked once per frame via + /// until it succeeds, tolerating a DAT/resource read that isn't ready + /// yet without failing the rest of the UI. + /// reads this property EXACTLY ONCE, during the single synchronous + /// startup composition pass (GameWindow.OnLoad) — unlike the + /// coordinator's own per-frame Tick, that one-shot GPU-resource + /// composition pass is NOT retried, matching every other private + /// viewport binding in that same method (paperdoll, creature appraisal) + /// — see that call site's own comment for the full disposition. + /// + /// internal UiViewport? ChargenPreviewViewportWidget => CharacterCreationController?.AppearanceViewport; diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 24b17201..7a798139 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -249,6 +249,12 @@ public sealed class UiScrollbar : UiElement return; } + if (ScalarChanged is not null) + { + DrawVerticalScalar(ctx, resolve); + return; + } + if (Model is not { } m) return; // Track background — TILED vertically (retail DrawMode=Normal). The native track @@ -313,6 +319,30 @@ public sealed class UiScrollbar : UiElement } } + /// + /// Fix round F11 (Campaign CC CC6b-MOUNT review): the mirror-image + /// counterpart of the horizontal scalar draw block above, for scalar-mode + /// bars authored VERTICAL (taller than wide) — retail's chargen shade + /// scrollbar (0x10000321) is one, measured against the installed + /// EoR dat (Width=33 Height=85). Retail's own + /// UIElement_Scrollbar is one class handling both a model-driven + /// list scroll and a scalar-value slider on EITHER axis; this class only + /// had the horizontal half of the scalar shape before this fix, so a + /// vertically-authored scalar bar (like the shade control) drew nothing + /// scalar-specific and fell through to the model-mode branch below, + /// which requires a a + /// scalar-mode bar never has. + /// + private void DrawVerticalScalar( + UiRenderContext ctx, Func resolve) + { + DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + float thumbHeight = ScalarThumbExtent(resolve, Height); + float travel = MathF.Max(0f, Height - thumbHeight); + float y = travel * ScalarPosition; + DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight); + } + /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) @@ -412,8 +442,17 @@ public sealed class UiScrollbar : UiElement if (e.Type == UiEventType.MouseMove) _hoveredButton = ButtonAt(e.Data1, e.Data2); - if (Horizontal && ScalarChanged is not null) - return OnScalarEvent(e); + // Fix round F11: retail's chargen shade scrollbar (0x10000321) is + // authored VERTICAL (measured against the installed dat), but a + // scalar-mode bar (ScalarChanged set, no Model) has always been + // possible on either axis in retail's own UIElement_Scrollbar. + // Gating this dispatch on Horizontal silently dropped every mouse + // event for a vertical scalar bar — it fell through the Horizontal + // Model branch below too, then hit "Model is not {} m => return + // false" since a scalar bar has no Model, so NOTHING ever routed to + // ScalarChanged in production for this orientation. + if (ScalarChanged is not null) + return Horizontal ? OnScalarEvent(e) : OnVerticalScalarEvent(e); if (Horizontal && Model is not null) return OnHorizontalModelEvent(e); @@ -590,14 +629,77 @@ public sealed class UiScrollbar : UiElement return false; } - private float ScalarThumbWidth(Func? resolve) + /// F11: the vertical mirror of — + /// same click-thumb-to-drag / click-track-to-jump shape, along Y/Height + /// instead of X/Width. Reuses (otherwise only + /// touched by the vertical MODEL-mode drag, mutually exclusive with + /// scalar mode on one instance) rather than adding a third offset field. + /// + private bool OnVerticalScalarEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.MouseDown: + { + float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); + float travel = MathF.Max(1f, Height - thumbHeight); + float thumbY = travel * ScalarPosition; + float y = e.Data2; + // OP5 re-check R2 (mirrored from OnScalarEvent): latch + // before the jump so the jump's own tick defers its flush + // to MouseUp's DragCompleted. + _draggingThumb = true; + if (y >= thumbY && y <= thumbY + thumbHeight) + { + _dragOffsetY = y - thumbY; + } + else + { + _dragOffsetY = thumbHeight * 0.5f; + ChangeScalarPosition((y - _dragOffsetY) / travel); + } + return true; + } + + case UiEventType.MouseMove when _draggingThumb: + { + float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); + float travel = MathF.Max(1f, Height - thumbHeight); + ChangeScalarPosition(((float)e.Data2 - _dragOffsetY) / travel); + return true; + } + + case UiEventType.MouseUp: + { + bool wasDragging = _draggingThumb; + _draggingThumb = false; + _pressedButton = EndButton.None; + if (wasDragging) DragCompleted?.Invoke(); + return true; + } + } + + return false; + } + + private float ScalarThumbWidth(Func? resolve) => + ScalarThumbExtent(resolve, Width); + + /// F11: generalized over so + /// can size the thumb along the + /// authored axis (native sprite width for a horizontal bar, native + /// sprite height for a vertical one) instead of assuming horizontal. + /// + private float ScalarThumbExtent( + Func? resolve, float axisLength) { if (resolve is not null && ThumbSprite != 0) { - var (_, width, _) = resolve(ThumbSprite); - if (width > 0) return MathF.Min(width, Width); + var (_, width, height) = resolve(ThumbSprite); + int native = Horizontal ? width : height; + if (native > 0) return MathF.Min(native, axisLength); } - return MathF.Min(16f, Width); + return MathF.Min(16f, axisLength); } private void ChangeScalarPosition(float position) diff --git a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs index fe1c7579..0142d4a9 100644 --- a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs +++ b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs @@ -17,6 +17,21 @@ namespace AcDream.Content.CharGen; /// live preview re-composes on every appearance change, and the same /// PalSet/ClothingTable ids repeat constantly across heritages, genders, and /// re-selections within one session. +/// +/// +/// NOT thread-safe on its own (fix round F7, CC6b-MOUNT review): +/// / do a lazy raw +/// _dats.Get<T>() read on first use per id — and the shared +/// DatCollection every sibling in this codebase guards with the +/// process-wide DAT lock is itself NOT thread-safe +/// (feedback_phase_a1_hotfix_saga.md). Every call site MUST hold that +/// same lock (ChargenPreviewController's _datLock, the +/// composition root's d.DatLock) around calls into this class, exactly +/// like every other DAT-touching call in this codebase already does. This +/// class's own caches only +/// protect the CACHE from concurrent mutation — they do nothing for the +/// underlying DatCollection read the cache miss triggers. +/// /// public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource { diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs index 0e291b6f..af7892f8 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Tests.UI.Layout; // InstalledDatFactAttribute (fix round F13) using AcDream.Content; using AcDream.Content.CharGen; using AcDream.Content.Vfx; @@ -22,6 +23,18 @@ namespace AcDream.App.Tests.Rendering; /// REAL dat-backed / /// so ChargenAppearanceFactory.TryCompose and /// ChargenPreviewEntityBuilder.TryBuildAnimated actually run. +/// +/// +/// Fix round F13: every case uses +/// (shared with +/// CharacterCreationLiveDatTests/CharacterManagementLiveDatTests) +/// instead of plain [Fact]. Before this fix, a bare [Fact] plus +/// 's own if (!TryOpen(...)) return; guard made +/// all six cases pass SILENTLY with zero assertions run whenever +/// ACDREAM_DAT_DIR was unavailable — indistinguishable in the test +/// runner's summary from an actual passing run. The attribute now reports +/// those runs as Skipped so the counts show "ran" separately from "no-op'd". +/// /// public sealed class ChargenPreviewControllerTests { @@ -31,7 +44,7 @@ public sealed class ChargenPreviewControllerTests private const uint AluvianId = 1u; private const uint GearknightId = 6u; - [Fact] + [InstalledDatFact] public void Rebuild_SameSelectionTwice_IsANoOpSecondTime() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -54,7 +67,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -101,7 +114,7 @@ public sealed class ChargenPreviewControllerTests /// changes ONLY the appearance selection (same heritage, same gender) /// must NOT snap the camera back to the heritage default. /// - [Fact] + [InstalledDatFact] public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -129,7 +142,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -160,7 +173,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -191,7 +204,7 @@ public sealed class ChargenPreviewControllerTests } } - [Fact] + [InstalledDatFact] public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication() { if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) @@ -230,6 +243,14 @@ public sealed class ChargenPreviewControllerTests private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) => (ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats)); + /// + /// Fix round F13: already gates + /// every case above this call, so datDir is null should not be + /// reachable in practice once a case actually runs — this stays as + /// defense-in-depth (a DAT directory that exists but a corrupt/renamed + /// client_portal.dat the attribute's own lighter check missed) + /// rather than a silent no-assertions pass. + /// private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter) { string? datDir = CornerFloodReplayTests.ResolveDatDir(); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 6fb39999..0c9c6cd8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -359,7 +359,17 @@ public sealed class CharacterCreationLiveDatTests CharacterCreationAppearancePage.FootwearSpinId, }) { - AssertButton(appearanceRoot, spinId); + UiButton spin = AssertButton(appearanceRoot, spinId); + // Fix round F2 item 2: the current-part highlight + // (TrySetRetailState(Normal/Highlight)) only has a visible + // effect through UiButton's ToggleBehavior branch when the + // authored spin actually sets DAT property 0x0B — measured + // (not assumed, matching this file's own discipline for the + // arrow geometry above) True for all nine spins against the + // installed EoR dat. Pinned so a future DAT revision that + // drops it shows up here instead of as a silently-dead + // highlight. + Assert.True(spin.ToggleBehavior, $"spin 0x{spinId:X8} must author ToggleBehavior for the current-part highlight to work."); } // Every color-wheel-family id resolves through EXISTING @@ -367,8 +377,20 @@ public sealed class CharacterCreationLiveDatTests // Type-3 fallback) — the risk-item-4 scouting result, pinned. foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) AssertButton(appearanceRoot, swatchId); - Assert.IsType( + UiScrollbar shadeScroll = Assert.IsType( UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId)); + // Fix round F11: measured (not assumed) against the installed EoR + // dat — the shade scrollbar (0x10000321) is authored VERTICAL + // (33x85, taller than wide). Before this fix, UiScrollbar.OnEvent + // only routed to ScalarChanged when Horizontal was true, so mouse + // input on this control never reached SetShadeFromScalar in + // production. Pinned so a future DAT revision that flips this + // orientation is caught here rather than silently reintroducing the + // dead-input bug (UiScrollbar's OnVerticalScalarEvent handles this + // orientation now, but ONLY this orientation gets exercised in + // production). + Assert.False(shadeScroll.Horizontal); + Assert.True(shadeScroll.Height > shadeScroll.Width); Assert.IsType( UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId)); @@ -426,6 +448,15 @@ public sealed class CharacterCreationLiveDatTests Assert.NotNull(increment); Assert.Equal(80f, decrement!.X); Assert.Equal(127f, increment!.X); + // Fix round F10: the arrow WIDTHS are what actually derive + // CharacterCreationAppearancePage.IncrementZoneEnd (174 = + // IncrementZoneStart 127 + this measured 47px width) — X alone + // pins the LEFT edge of each zone, not where the increment zone + // ends and the select-as-current-part body zone begins. Measured + // against the installed EoR dat: both arrows are 47px wide, + // uniformly, across all nine spins. + Assert.Equal(47f, decrement.Width); + Assert.Equal(47f, increment.Width); } } @@ -457,7 +488,7 @@ public sealed class CharacterCreationLiveDatTests private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); - private static void AssertButton(UiElement root, uint elementId) => + private static UiButton AssertButton(UiElement root, uint elementId) => Assert.IsType(UiElement.FindDescendant(root, elementId)); private static ImportedLayout BuildSelected( diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 6f797a4a..567391bb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -527,21 +527,88 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); } + /// + /// Fix round F1: this test previously asserted index 0 here, pinning a + /// doc claim ("no decomp-observable Unset-cycling case") the decomp + /// refutes. Hair's own decrement case + /// (gmCGAppearancePage::ListenToElementMessage @0x0047f465-0x0047f086, + /// the same shared tail the headgear ring reuses at + /// label_47f065/label_47f6d9) computes + /// new = cur - 1 on the raw signed int32 (Unset = -1), giving + /// new = -2; since -2 < 0 it wraps to count - 1, + /// NOT 0 — see 's + /// own corrected doc. The fixture's Hair style count is 3 + /// (), so the expected + /// landing index is 2. + /// [Fact] - public void AppearanceSpin_DecrementZoneClick_FromUnset_StartsAtStyleZero() + public void AppearanceSpin_DecrementZoneClick_FromUnset_WrapsToLastStyle() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); SelectAluvianMale(environment); - // Non-headgear spins have no decomp-observable Unset-cycling case - // (retail always has a real 0-based index by the time the user can - // click — CycleIndex's own citation) — a first click from Unset in - // EITHER direction just starts cycling at style 0, not a ring. environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10); Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); + } + + /// + /// Fix round F10: pins the SELECT zone — x=[174,200), right of the + /// increment arrow's own x=[127,174) (live-DAT-measured, both arrows + /// 47px wide — CharacterCreationLiveDatTests) — as a body click + /// that selects the part WITHOUT invoking another style cycle, once the + /// part already holds a real (non-Unset) index. Distinguishes the third + /// OnClickAt zone from the two arrow-zone tests above, which no + /// prior test isolated. + /// + [Fact] + public void AppearanceSpin_SelectZoneClick_SelectsPartWithoutChangingIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Establish a real Hair index first (increment zone, x=150). + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10); Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + int callsAfterCycle = environment.Runtime.AppearanceIndexCallCount; + + // x=180 is inside [174,200) — past the increment arrow's own zone, + // still inside the 200px-wide spin — the spin's own BODY, not + // either arrow. + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10); + + // Already in [0,count) — NormalizeChoiceOnSelect (F1) is a no-op, + // so the select zone must not re-invoke SetAppearanceIndex. + Assert.Equal(callsAfterCycle, environment.Runtime.AppearanceIndexCallCount); + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + } + + /// + /// Fix round F1: pins the body-click normalize-and-write-back + /// ('s own + /// NormalizeChoiceOnSelect doc) for the case that's actually + /// reachable in acdream — a part still Unset (AP-214 honest-blank open) + /// gets clicked in its SELECT zone (not an arrow) — wraps to + /// count-1 and writes it back, exactly like a decrement click + /// would, even though no arrow was pressed. + /// + [Fact] + public void AppearanceSpin_SelectZoneClick_FromUnset_NormalizesToLastStyle() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Hair starts Unset (AP-214 honest-blank). x=180 is the select + // zone, not either arrow. + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(180, 10); + + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(2u, environment.Runtime.LastAppearanceIndex); // count-1, fixture has 3 hair styles. } /// CharGenState::SetHeadgearStyle's decomp-derived @@ -576,7 +643,12 @@ public sealed class CharacterCreationUiControllerTests [InlineData(1u, -1, 3, false, 0u)] [InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start. [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)] - [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 0u)] + // Fix round F1: decrement-from-Unset wraps to count-1 (2), not 0 — the + // decomp's shared decrement tail (label_47f065/label_47f6d9) computes + // new = cur - 1 = -2 on the raw signed int32, which is < 0, so it wraps + // to count-1 exactly like headgear's own ring does for its non-Unset + // range. See CycleIndex's own corrected doc. + [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 2u)] [InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset. [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0. [InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset. diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index c59fa3d8..7a1b0441 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -103,6 +103,43 @@ public class UiScrollbarTests Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45))); } + /// + /// Fix round F11 (Campaign CC CC6b-MOUNT review): retail's chargen shade + /// scrollbar (0x10000321) is authored VERTICAL (measured 33x85 + /// against the installed dat — see + /// CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport), + /// but before this fix UiScrollbar.OnEvent only routed to + /// ScalarChanged when Horizontal was true — a vertical + /// scalar bar's clicks fell through to the Model-mode branch, which + /// returns false with no set, so the + /// shade control never fired in production. Mirrors + /// + /// exactly, transposed onto Y/Height/Data2. + /// + [Fact] + public void VerticalScalar_clickAndDrag_updatesNormalizedValue() + { + float value = 1f; + var bar = new UiScrollbar + { + Width = 14f, + Height = 90f, + Horizontal = false, + ScalarChanged = next => value = next, + }; + bar.SetScalarPosition(1f); + + Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data2: 8))); + Assert.Equal(0f, value, 3); + Assert.Equal(0f, bar.ScalarPosition, 3); + + Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data2: 45))); + Assert.Equal(0.5f, value, 3); + Assert.Equal(0.5f, bar.ScalarPosition, 3); + + Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data2: 45))); + } + // ── OP5 review fix S1: the drag-end seam (IsDragging / DragCompleted) ──── [Fact] From 6114b2dda29ca92409a96bfe5d8c2d51129f1dab Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 22:58:19 +0200 Subject: [PATCH 098/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC6b-MO?= =?UTF-8?q?UNT=20re-review=20residuals=20R1-R3=20+=20nits=20=E2=80=94=20RE?= =?UTF-8?q?VIEW-CLOSED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1: LivePresentationComposition's chargen-preview diagnostic fired on every ordinary launch without ACDREAM_RETAIL_UI set, since interaction.RetainedUi is null in that configuration and there's no Appearance page to warn about. Narrowed the else-if guard to require RetainedUi is not null too, so the diagnostic only fires in the one configuration it actually diagnoses. R2: filed AP-221 for the F8 one-shot-binding disposition the re-reviewer accepted as scoped but which shipped without its own register row — the chargen preview's GPU-side binding reads the retryable mount coordinator's widget exactly once, so a slow-DAT frame permanently kills the preview for the session with only R1's diagnostic as evidence. R3: rewrote AP-217 after re-deriving from the decomp. The original row claimed the GradCircle was an interactive click-to-hue picker with no handler wired up. gmCGAppearancePage::ListenToElementMessage's dispatch switch has no case for the GradCircle's offset at all — it isn't a click target in retail either. DoGradDisk is a paint-only routine that blits the gradient art tinted with the current color (or blanks it for Eyes) whenever SetColor/SetSelection run. acdream's real gap is that it never repaints the GradCircle — a cosmetic paint gap, not a dead control. N1: tightened AP-220's "leaving Gearknight for something else" — the decomp shows leaving Gearknight for Olthoi/OlthoiAcid takes a separate branch that does not randomize; only leaving for a non-Olthoi heritage does. N2: added the requested media pin to the F2 spin-highlight live-DAT test, then measured it against the installed EoR dat rather than assuming it would pass. It doesn't: none of the nine spins author Highlight-state media on either consumed arrow face segment, so TrySetRetailState(Highlight) is a silent no-op for all of them today. Pinned the test to the measured reality (ActiveState stays "Normal") and filed AP-222 documenting the discovery — unresolved whether retail's own spin art has the same gap. Plan doc: CC6b-MOUNT ledger row updated to REVIEW-CLOSED with the full commit chain and re-review disposition; OWED list corrected for AP-217/ AP-222. Gates: dotnet build -c Release green. App suite 5223 passed / 3 skipped (ACDREAM_PROBE_LIVE_MOUNT=1, ACDREAM_DAT_DIR set) — count held exactly at baseline. Runtime suite 1713/0 — unchanged. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 8 +++-- .../2026-08-15-character-creation-campaign.md | 2 +- .../LivePresentationComposition.cs | 14 ++++++++- .../Layout/CharacterCreationLiveDatTests.cs | 30 +++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 940fff66..fc44fb20 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 156 active rows (AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 158 active rows (AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -391,10 +391,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions).** Two narrow, DECIDED substitutions where acdream reaches the same functional selection through a different widget mechanism than retail's own: (1) the nine color swatches (`0x1000030f-0x10000317`) use their own `UiButton.Selected` highlight state for "this is the current color" instead of toggling the separate Type-3 companion overlay element (`0x10000318-0x10000320`) retail's `SetColor @ 0x0047DD50` shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — the composited pixel result is UNVERIFIED to match, not asserted identical (same "measured, not assumed" discipline AD-103's own F5 note established for a different swallowed-child case). (2) the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons is out of this round's scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop; `SetStyleSpinLabel`) | Both substitutions reach the SAME underlying selection (the swatch highlight still shows which color index is active; the ordinal still lets a player cycle deterministically and see which slot they're on) through existing widget primitives (`UiButton.Selected`, `UiButton.Label`) rather than adding new rendering infrastructure (a second overlay-visibility channel, or an icon-texture pipeline) this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a different (simpler) selected-swatch visual and text labels where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close the label half of this row. | `gmCGAppearancePage::SetColor @0x0047DD50` (the `m_tColorWheel` overlay toggle); `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | | AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1).** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. acdream's swatches show only their authored (static) DAT art regardless of which color they represent or whether the current part even has that many colors — AP-215's `.Selected` substitution covers WHICH swatch is chosen, not what each swatch itself looks like. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — sets `.Selected` only, never touches swatch appearance) | The nine swatches already reach the correct SELECTION semantics through `DatWidgetFactory`'s existing `UiButton` primitive; painting each swatch with a computed color needs either a per-swatch dynamic-color render path (new UI infrastructure this scope doesn't otherwise need) or a fallback to static art, which is what this round shipped. | A side-by-side against retail shows every swatch drawing the SAME authored art regardless of which color it represents, and swatches beyond a part's real color count staying visibly "on" instead of blanking — a real visual gap on a screen the player stares at while picking a color, not a selection-correctness gap. | `gmCGAppearancePage::DoColorSpots @0x0047d850` | -| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4) — corrects the campaign plan's own CC6b-MOUNT ledger row, which wrongly claimed AP-215 already named the GradCircle.** Retail's `gmCGAppearancePage::DoGradDisk @0x0047da90` drives the GradCircle (`0x1000030e`) as an interactive hue/gradient picker, click-mapped to a color. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback (the risk-item-4 color-wheel scouting result) with no click handling wired to it at all — it is purely decorative in this round. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page's own constructor never binds a handler to it) | The nine swatch buttons already provide a full, decomp-cited color-selection path (`SetColor`'s own cases `5`-`0xd`); the GradCircle's own click-to-color-position mapping has no decomp citation yet in this campaign's research. | A user clicking the GradCircle in acdream gets no response at all, where retail would change the current part's color — a dead-control gap a visual gate would surface immediately. | `gmCGAppearancePage::DoGradDisk @0x0047da90` | +| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3) — the original row misdescribed both the retail mechanism and the acdream gap.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes — the GradCircle is authored, retail-driven *decorative art reflecting the current color*, not an input control. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback and never paints it: no `BlitAndColor`-equivalent repaint on color change, and no Eyes-blank equivalent. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page never repaints it) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint (a `Blit_Multiply` composite against `m_pGradGraphic`/`m_pGradPlug`, not a click handler) is separate follow-up work with no decomp citation yet for the composite art assets. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color (and never blanking for Eyes) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | | AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | -| AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7).** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for something else (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6`). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | +| AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | +| AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`) and creature-appraisal in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of the CC6b-MOUNT fix round's blast radius; the re-reviewer accepted the narrower diagnostic-only fix (R1) as sufficient for this round with this row as the tracked follow-up. | On the specific unlucky frame where the coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose 3D preview zoom/rotate controls are dead for the ENTIRE session with no visible error beyond the (narrowed) console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of this binding (CC5 or a follow-up slice) should close. | `src/AcDream.App/Composition/LivePresentationComposition.cs:996-1104` (chargen preview block's own F8 disposition comment); `RetailUiRuntime.ChargenPreviewViewportWidget`'s doc comment (retry-vs-one-shot contrast) | +| AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | | AP-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). **Latent Finish-path interaction noted at the CC6b-MOUNT review fix round (F12, 2026-08-15):** with AD-101 retired, honest-blank heritage/gender means `RuntimeCharacterCreationState.TryBeginFinish` can be reached with `_genderKey == 0` (or an unselected heritage) — `TryBeginFinish`'s four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no heritage/gender gate today. Currently LATENT ONLY (Finish is hard-disabled + `OnClick` null this round — TS-82); CC5's own scope is now AMENDED (see the plan doc's Slices table) to land BOTH a heritage/gender refusal in `TryBeginFinish` AND a real `RandomizeCharacter` port before the connected user gate opens Finish for real use, since a gate alone does not reproduce retail's actual guarantee (retail's ctor-time `RandomizeCharacter` means heritage/gender are NEVER unset by the time a player can reach Finish at all). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (CC6b-MOUNT review fix F5 correction: NOT a placeholder — retail's own `DoRandom` case 3 fully enables Random here; the disable rests on the same unported `RandomizeAppearance`/`RandomizeClothing` primitives this row already names), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index f7fa2011..187ba0e4 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -258,4 +258,4 @@ the user gate. **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | -| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, (this commit — the review fix round) | fix round landed F1-F13, narrow re-review pending | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own interactive click-to-hue behavior (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217 — the GradCircle is currently a non-interactive static container this round, since its own click-to-color-position mapping has no decomp citation yet and the nine swatch buttons already provide a full, decomp-cited color-selection path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it. | +| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, `d2a71152`, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round `d2a71152` → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of `d2a71152` (R3) after re-deriving from the decomp: `gmCGAppearancePage::ListenToElementMessage`'s own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — `DoGradDisk` is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever `SetColor`/`SetSelection` run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; **the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of `d2a71152`, N2)** — none of the nine spins author Highlight-state media, so `RefreshColorAndShadeControls`'s `TrySetRetailState(Highlight)` call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. | diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index c9c4d8db..a598663e 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1078,13 +1078,25 @@ internal sealed class LivePresentationCompositionPhase } }); } - else if (dispatcherLease.Resource is not null) + else if (dispatcherLease.Resource is not null && interaction.RetainedUi is not null) { // Fix round F8: dispatcher is available but the mount coordinator // hadn't resolved ChargenPreviewViewportWidget by this one-shot // pass — loud instead of silent, since the coordinator's own // later per-frame retries cannot recover this GPU-side binding // (see this block's own disposition comment above). + // + // Re-review R1: the retained UI arm (`interaction.RetainedUi`) + // is null in the default configuration (ACDREAM_RETAIL_UI + // unset — see InteractionRetainedUiComposition.cs's own gate on + // RuntimeOptions.RetailUi), and in that configuration there is + // no Appearance page at all. The dispatcher lease is + // acquired unconditionally regardless of retained-UI presence, + // so without this second guard every ordinary launch printed + // this diagnostic even though nothing was actually broken. + // Narrowed to fire only in the one configuration it is meant to + // diagnose: retained UI mounted, dispatcher ready, but the + // coordinator's widget resolution missed this one-shot pass. Console.WriteLine( "[UI] chargen preview viewport unavailable at composition " + "time — the Appearance page's zoom/rotate controls and " diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 0c9c6cd8..347b092f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -370,6 +370,36 @@ public sealed class CharacterCreationLiveDatTests // drops it shows up here instead of as a silently-dead // highlight. Assert.True(spin.ToggleBehavior, $"spin 0x{spinId:X8} must author ToggleBehavior for the current-part highlight to work."); + // Re-review nit N2 (2026-08-15): ToggleBehavior alone is necessary + // but not sufficient — UiButton.UpdateVisualState only COMMITS the + // requested state when _availableStates actually contains it + // (UiButton.cs's TrySetRetailState -> Selected setter -> + // UpdateVisualState chain). MEASURED (not assumed) against the + // installed EoR dat: TrySetRetailState(Highlight) itself always + // reports success (the ToggleBehavior branch commits + // unconditionally, matching TrySetRetailState's own contract), + // but NONE of the nine spins actually carries Highlight / + // Highlight_rollover / Highlight_pressed media on either of + // their two consumed arrow face segments — every one of them + // authors only Normal / Normal_rollover / Ghosted. So F2 item 2's + // current-part highlight is CURRENTLY A NO-OP for every spin: + // ActiveState silently stays at its prior value ("Normal") + // instead of ever becoming "Highlight". The pre-existing + // ToggleBehavior pin above never caught this because it only + // checks the PROPERTY that gates the state-machine branch, not + // whether that branch has anything to actually draw. Filed as + // AP-222 (retail-vs-acdream status unresolved — retail's own + // gmCGAppearancePage::SetSelection call sites are cited for + // the SetState(1)/SetState(6) calls, not for whether retail's + // OWN spin art authors Highlight media either). Pinned to + // "Normal" so a future DAT revision that adds real Highlight + // media is what makes this assertion start failing — the + // correct trigger to update it to "Highlight" instead of a + // silently-reintroduced dead highlight going unnoticed either way. + Assert.True( + spin.TrySetRetailState(UiButtonStateMachine.Highlight), + $"spin 0x{spinId:X8} must accept a Highlight state request."); + Assert.Equal("Normal", spin.ActiveState); } // Every color-wheel-family id resolves through EXISTING From 34e3a534be36d8887af66eb84520df2fb660dedb Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 00:01:07 +0200 Subject: [PATCH 099/138] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice?= =?UTF-8?q?=20CC5=20=E2=80=94=20Summary=20page,=20Finish=20flow,=20Randomi?= =?UTF-8?q?zeCharacter=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills TS-82's Summary placeholder with a faithful port of gmCGSummaryPage (name field with NameInputFilter + the retail commit-on-focus-lost/submit dispatch + the >32-char ID_CharGen_NameTooLong reject-and-revert path, the REAL three-row-template listbox confirmed against the installed EoR dat before writing any page code, and Summary's own independent gmCG3DView preview instance wired through a second ChargenPreviewController pair mirroring the Appearance page's exact composition shape). Ports CharGenState::RandomizeCharacter and its six sub-primitives into RuntimeCharacterCreationState — not approximated: the RandInt/RollDice semantics are independently confirmed from both the decompiled RNG bodies and the CharGenStateVtbl union struct in acclient.h. Three consumers: the chargen screen's open-roll (retiring AP-214's honest-blank deviation and reproducing the Appearance page's gender-flip-on-init quirk), the Summary page's Random button (behind the retail randomize-warning confirm), and the Appearance page's Random button (narrowing AP-212 to just Heritage/Profession/Town's still-approximated rolls and Skills' still-unported RandomizeSkills). Wires the Finish button (previously ghosted) with retail's NoName/ CreditWarning dialog pair, adds the F12 amendment's HeritageOrGenderUnset local refusal to TryBeginFinish (register AP-223) as a defensive backstop now that the screen-open roll normally makes it unreachable, and wires the four ID_Character_Err_* rejection dialogs for the 0xF643 response codes CC3 already parsed but nothing displayed. Register: TS-82 retired, AP-214 retired, AP-212 narrowed, AP-223/224/225 filed (heritage/gender Finish refusal, Summary's two-bucket skill-list narrowing, the 32-vs-33 name-length threshold reconciliation). Runtime 1722/0 (was 1713), App 5240/3 skips (was 5223/3), Headless 166/0 unchanged, full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 11 +- .../2026-08-15-character-creation-campaign.md | 2 +- .../Composition/FrameRootComposition.cs | 3 +- .../InteractionRetainedUiComposition.cs | 5 + .../InteractionUiRuntimeSources.cs | 22 + .../LivePresentationComposition.cs | 83 +++ .../Rendering/ChargenPreviewController.cs | 13 + src/AcDream.App/Rendering/GameWindow.cs | 8 + .../Rendering/GameWindowLifetime.cs | 10 + .../Runtime/CurrentGameRuntimeAdapter.cs | 15 + .../Layout/CharacterCreationAppearancePage.cs | 20 + .../UI/Layout/CharacterCreationSummaryPage.cs | 372 ++++++++++++ .../Layout/CharacterCreationUiController.cs | 324 ++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 57 +- src/AcDream.Runtime/GameRuntimeCommands.cs | 24 + .../Session/LiveSessionController.cs | 39 ++ .../Session/RuntimeCharacterCreationState.cs | 449 ++++++++++++++- .../Layout/CharacterCreationLiveDatTests.cs | 59 +- .../CharacterCreationUiControllerTests.cs | 534 +++++++++++++++++- ...CharacterScreensFixedCanvasArbiterTests.cs | 3 +- .../RuntimeCharacterCreationStateFixture.cs | 49 +- .../RuntimeCharacterCreationStateTests.cs | 194 +++++++ 22 files changed, 2217 insertions(+), 79 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index fc44fb20..1e16834c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 158 active rows (AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 160 active rows (AP-223/AP-224/AP-225 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal, the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing, and the Summary name field's 32-vs-33 length-threshold reconciliation; AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -207,6 +207,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-225 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary page's name field).** Retail's chargen name buffer is `char name[33]` (32 usable chars + null terminator — `CharGenState`'s own struct field, `acclient.h`). The UI-side pre-commit length check at `gmCGSummaryPage::ListenToElementMessage @ 0x0047bf40` (`~0x0047bfd1`) compares the raw input against the literal `0x21` (33), rejecting anything longer — but the exact base of that decompiled comparison (visible character count vs. an internal length-prefix accounting the decompiler didn't resolve cleanly) is not fully certain from the pseudo-C. `CharacterCreationSummaryPage`'s own `MaxNameLength` uses 32, matching the ALREADY-ESTABLISHED `RuntimeCharacterCreationState.TrySetName` storage cap (CC3), rather than trusting the ambiguous 1-off literal over that reviewed contract — a name of exactly 33 characters is the only value where the two thresholds could disagree. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`MaxNameLength`) | Internal consistency between the UI-level reject-and-revert threshold and the Runtime storage cap is more valuable than an unverified 1-character decomp literal — a real divergence here would show up as "the field accepts 33 characters but the create sends 32," which this alignment prevents by construction. | If retail's actual usable cap is genuinely 33 (not 32), a 33-character name that should be accepted gets rejected with the too-long dialog instead — a narrow, one-character-wide UX mismatch, never a data-corruption risk (the wire format truncates to whatever `TrySetName` already stores either way). | `gmCGSummaryPage::ListenToElementMessage @ 0x0047bf40`; `CharGenState.name[33]` (`acclient.h`); `RuntimeCharacterCreationState.TrySetName` (CC3) | +| AP-224 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary listbox content).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` walks FOUR skill buckets (Specialized, Trained, UseableUntrained, UnuseableUntrained) and lists every skill name in each, via a nested loop over `skillRecordList`. `CharacterCreationSummaryPage.AddSkillBucket` lists Specialized and Trained only, skipping the two Untrained buckets — mirroring AP-213's own already-accepted Skills-page simplification precedent (same class of cut: presentation grouping, not correctness). Health/Stamina/Mana values reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas (Health=Endurance/2, Stamina=Endurance, Mana=Self) rather than this page's OWN `SetSummaryText` call site, whose two `GetAttribute` calls for Health/Stamina both show a literal attribute index of `2` in the decompiled pseudo-C — a decompiler-ambiguous pair the cleaner Profession-page citation sidesteps rather than reproduces uncritically. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`RebuildListbox`, `AddSkillBucket`) | The two Untrained buckets would list the ~40+ skills the player did NOT touch — volume without decision-relevant information for a pre-Finish review screen; every skill's actual cost/level data remains identical and inspectable on the Skills page itself. The Health/Stamina/Mana citation choice favors a decomp site with an unambiguous formula over one with a decompiler artifact. | A player scanning Summary for "what am I NOT trained in" has to go back to the Skills page instead of seeing it listed here — a discoverability gap, not a correctness gap; the row TEMPLATE mechanism itself (three retail row types: single-line, header, key/value pair) is ported exactly, live-DAT-probe-confirmed, not simplified. | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0`; `CharacterCreationProfessionPage.Refresh`'s own `UpdateAttributeValues @ 0x00482450` citation | +| AP-223 | **Filed 2026-08-15 at Campaign CC slice CC5 (the F12 amendment's own explicit ask — see AP-214's now-retired "Latent Finish-path interaction" note).** `RuntimeCharacterCreationState.TryBeginFinish` gains a NEW local refusal, `HeritageOrGenderUnset`, checked right after the empty-name check. Retail's own `gmCharGenMainUI::DoFinish @ 0x004E9170` has NO such check in the decompiled code — but it doesn't need one: `RandomizeCharacter` at ctor time (now ported, see AD-101/AP-212/AP-214's history) guarantees heritage+gender are ALWAYS real by the time any page — including Summary/Finish — exists. This refusal is acdream's OWN defensive backstop for a caller that reaches `Finish` without that screen-open roll ever having run (a headless bot driving `RuntimeCharacterCreationState` directly, or a future caller that bypasses `CharacterCreationUiController.Open`). Under the ordinary UI it is normally unreachable (the roll always fires first). | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`, `TryBeginFinish`) | Retail's own guarantee is architectural (a roll that always runs before any page exists), not a runtime check — acdream's UI reproduces the roll (`CharacterCreationUiController.Open` → `RollOpeningCharacter`) but a direct Runtime caller could still skip it, so a local refusal is the honest choice over silently sending a heritage-0/gender-0 wire request ACE would likely reject anyway for unrelated reasons. | A caller that bypasses the normal screen-open path and calls `Finish` before ever selecting heritage/gender gets a local refusal instead of a wire round-trip to discover the same failure — no server-visible consequence either way. | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`, the ctor-time roll); `CharGenState::RandomizeCharacter @0x005c6d80`; `gmCharGenMainUI::DoFinish @ 0x004E9170` (no heritage/gender check present) | | AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | | ~~AP-201~~ | **RETIRED 2026-08-11 at the Campaign OP gate-3 fix round (closes #371).** UiScrollablePanel now marks ClipsChildren=true (the draw walk and hit-test both route through UiRenderContext.PushClip, which existed by retirement time) and its cull predicate keeps any INTERSECTING row visible - a straddling row renders its visible slice instead of vanishing whole. The user-observed symptom this row predicted (the Chat tab per-window filter blocks reading as MISSING at the default scroll offset, gate 3) is the exact acceptance evidence. Original filing follows for the record: filed at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it. `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` | @@ -397,12 +400,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`) and creature-appraisal in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of the CC6b-MOUNT fix round's blast radius; the re-reviewer accepted the narrower diagnostic-only fix (R1) as sufficient for this round with this row as the tracked follow-up. | On the specific unlucky frame where the coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose 3D preview zoom/rotate controls are dead for the ENTIRE session with no visible error beyond the (narrowed) console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of this binding (CC5 or a follow-up slice) should close. | `src/AcDream.App/Composition/LivePresentationComposition.cs:996-1104` (chargen preview block's own F8 disposition comment); `RetailUiRuntime.ChargenPreviewViewportWidget`'s doc comment (retry-vs-one-shot contrast) | | AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | -| AP-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). **Latent Finish-path interaction noted at the CC6b-MOUNT review fix round (F12, 2026-08-15):** with AD-101 retired, honest-blank heritage/gender means `RuntimeCharacterCreationState.TryBeginFinish` can be reached with `_genderKey == 0` (or an unselected heritage) — `TryBeginFinish`'s four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no heritage/gender gate today. Currently LATENT ONLY (Finish is hard-disabled + `OnClick` null this round — TS-82); CC5's own scope is now AMENDED (see the plan doc's Slices table) to land BOTH a heritage/gender refusal in `TryBeginFinish` AND a real `RandomizeCharacter` port before the connected user gate opens Finish for real use, since a gate alone does not reproduce retail's actual guarantee (retail's ctor-time `RandomizeCharacter` means heritage/gender are NEVER unset by the time a player can reach Finish at all). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | -| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (CC6b-MOUNT review fix F5 correction: NOT a placeholder — retail's own `DoRandom` case 3 fully enables Random here; the disable rests on the same unported `RandomizeAppearance`/`RandomizeClothing` primitives this row already names), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | +| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 50 active rows (TS-82 filed 2026-08-15 at Campaign CC slice CC4 — the Appearance/Summary page roots mount empty and content-inert, reachable via free tab navigation, pending CC5/CC6a/CC6b; TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -414,7 +416,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-78 | "Use Main Pack as Default for Picking Up Items" (`PlayerOption MainPackPreferred`) has no acdream consumer — retail's `CPlayerSystem::PlaceInBackpack @0x0055d8c0` chooses which container a picked-up item lands in client-side; acdream's pickup path (`SendPickup`) has no client-side preferred-container selection at all today. | item-pickup path (`src/AcDream.App/UI/ItemInteractionController.cs` and siblings) — no consumer wired | A real consumer needs the client-side container-preference decision retail's `PlaceInBackpack` makes, which does not exist in the current pickup flow — future scope. | Toggling the option writes the bit and dirties/auto-saves it correctly, but item pickups route exactly as before (server-decided placement). | `CPlayerSystem::PlaceInBackpack @0x0055d8c0` | | TS-79 | Group D (plan §4 OP4): "Salvage Multiple Materials at Once" (`SalvageMultiple`) and "Disable House Restriction Effects" (`DisableHouseRestrictionEffects`) have no acdream consumer — acdream has no salvage UI (`gmSalvageUI`) and no housing subsystem (`ACCWeenieObject::CanMoveInto`) for either option to gate. | no consumer — both are Character-tab rows, wire+store only | Both require whole unbuilt subsystems (salvage crafting UI; player housing); inventing a stand-in is out of scope for a settings-panel slice. | Toggling either option writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes (both are also currently unreachable — no salvage UI, no housing). | `gmSalvageUI::IsItemSuitable @0x004cb040`; `ACCWeenieObject::CanMoveInto @0x0058da40` | | TS-80 | "Share Fellowship Experience and Luminance" (`PlayerOption FellowshipShareXP`) is Group D's one CLIENT-SOURCED option (character-options-map.md §3): retail's `gmFellowshipUI::CreateFellowship` reads the option value and puts it directly in the fellowship-CREATE wire action; ACE takes XP-sharing from that packet field, never from the stored `CharacterOptions1` bit (`Entity/Fellowship.cs:31,53-54`). Storing the bit alone (this slice's row) is necessary but not sufficient — acdream's own fellowship-create action does not yet read it into the create packet. **PARTIALLY NARROWED 2026-08-12 at Campaign FA slice FA2: the wire mechanism now exists end-to-end — `IRuntimeFellowshipCommands.Create(gen, name, shareXp)` takes and sends `shareXp` on `0x00A2` — but no caller reads `FellowshipShareXP` into that parameter yet (the create dialog is FA4 scope); the risk below is unchanged until that UI lands.** | fellowship-create action (`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs` `Create`; `src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs` `Create`) — takes `shareXp` as an explicit caller-supplied argument, not yet fed from the option bit | Filed rather than silently assumed correct — a bit that LOOKS wired (toggles, persists, sends `0x0005`) but is never actually consulted by fellowship creation would silently share/withhold XP incorrectly the moment a fellowship is created. | Toggling the option and then creating a fellowship may not honor the toggle — the created fellowship's actual XP-share setting depends on whatever caller value FA4's create dialog passes, unaudited by this slice. | `gmFellowshipUI::CreateFellowship` (address not captured this slice); ACE `Entity/Fellowship.cs:31,53-54` | -| TS-82 | **Filed 2026-08-15 at Campaign CC slice CC4. NARROWED to Summary-only 2026-08-15 at Campaign CC slice CC6b-MOUNT.** The Summary (`0x100003d6`, `gmCGSummaryPage`) page root mounts as an EMPTY, content-inert placeholder — visible/reachable through the master shell's free tab navigation (a player can click the Summary tab and land on a blank page) but with none of retail's own controls built: no name field, no summary listbox, no static preview. Explicitly scoped to CC5 (Summary + the Finish gate's real UI). **The Appearance page (`0x100003d4`, `gmCGAppearancePage`) is CLOSED OUT OF THIS ROW as of CC6b-MOUNT** — it now has real gender/Face-Clothes/spin/color-swatch/shade/zoom/rotate controls and a live 3D preview (`CharacterCreationAppearancePage`), so it is no longer content-inert. The master shell already ports retail's OWN visibility/state-toggle/tab-selection mechanics for the Summary page faithfully — only its CONTENT is stopgapped. | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`_summaryPageRoot`, mounted but no page controller attached) | The explicitly sequenced follow-on slice CC5 owns Summary's content; building it here would duplicate work already scoped to that slice and risk drifting from its own DAT/decomp research (the name-input filter, the summary listbox, the static preview). | A player reaching Summary via free tab navigation sees an empty page instead of retail's controls; Finish stays ghosted (**review fix round F11 (2026-08-15) — corrected cross-reference: this row's OWN CC5 dependency, not AP-211**, which is an unrelated roster-slot-cap local refusal — `CharacterCreationUiController`'s `_finish.OnClick = null` ctor comment names this row directly as the reason Finish has no handler this slice) so no create can complete through this screen until CC5 wires the Summary page's name field and the real Finish gate. | `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slice CC5) | | TS-81 | `0x027A AllegianceLoginNotification`'s retail-faithful two-line chat text (lane C §1.6/§7.1: "is the guid in my cached profile" gate, then a logged-on/logged-off line) is NOT emitted. `RuntimeAllegianceState.ApplyLoginNotification` bumps the snapshot revision only. Retail's own handler chain (`ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0` → `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330` → `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220`) resolves its logged-on/logged-off string via two symbols the Binary Ninja decompiler mis-labels as `gmAllegianceUI::\`vftable'.RecvNotice_PrevSpellTab`/`RecvNotice_UpdateSpellComponents` — a decompiler artifact (the address holds a DAT string-table reference, not those vtable slots; same class CLAUDE.md's BN-literal-0 caution warns about) that must be resolved via `compute_str_hash`/DAT string-table lookup, not guessed. Filed rather than inventing English for the two lines. | `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyLoginNotification`) | CLAUDE.md's "no invented user-visible English ever" rule — the candidate strings are BN-mislabeled and unverified from primary source; guessing here is exactly the negligence the workflow rules forbid. | A player never sees retail's "X has logged on/off" allegiance notice; the event still fires and updates Runtime state (usable for a future bot/UI poll), just with no chat line. | `ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0`; `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330`; `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` | | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | | ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 187ba0e4..98b36370 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -252,7 +252,7 @@ the user gate. | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | -| CC5 | — | | | | +| CC5 | CODE-COMPLETE 2026-08-15 | (this session's commit(s) — see git log for `feat(chargen): Campaign CC slice CC5`) | OWED (dual-lens review pending) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied` (Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` already treats them as a silent reset with no `RuntimeCharacterCreationRejection` produced at all); dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 6f1deeb5..2d31ed23 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -540,7 +540,8 @@ internal sealed class FrameRootCompositionPhase new PrivateEntityViewportFrameGroup( live.PaperdollPresenter, live.CreatureAppraisalPresenter, - live.ChargenPreviewController), + live.ChargenPreviewController, + live.SummaryPreviewController), retainedGameplayUi, // The ImGui developer-tools frontend was removed at Campaign V // slice V11; this optional hook is unbound until a follow-up diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 53ab9be4..c24fcbeb 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -1007,6 +1007,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory DatStringResolver.ComputeHash(key)); } }, + SetName: late.GameRuntime.CharacterCreationSetName, + AcknowledgeRejection: late.GameRuntime.CharacterCreationAcknowledgeRejection, + RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter, + RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, + RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, OpenOnStart: d.Options.OpenCharacterCreationOnStart) : null); RetailUiRuntime runtime = lease.Mount( diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index f61e0651..185032e5 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -241,6 +241,28 @@ internal sealed class DeferredGameRuntimeStateCommands Invoke((commands, generation) => commands.CharacterCreation.SetShade(generation, slot, value)); + // ── Campaign CC slice CC5: Summary page + RandomizeCharacter commands ── + + public RuntimeCommandResult CharacterCreationSetName(string name) => + Invoke((commands, generation) => + commands.CharacterCreation.SetName(generation, name)); + + public RuntimeCommandResult CharacterCreationAcknowledgeRejection() => + Invoke((commands, generation) => + commands.CharacterCreation.AcknowledgeRejection(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeCharacter() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeCharacter(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeAppearance() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeAppearance(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeClothing() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeClothing(generation)); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index a598663e..a623d31e 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -132,6 +132,11 @@ internal sealed record LivePresentationResult( // a leased composition resource, mirroring PaperdollViewportRenderer). ChargenPreviewRenderer? ChargenPreviewRenderer, ChargenPreviewController? ChargenPreviewController, + // Campaign CC slice CC5: the Summary page's own gmCG3DView instance — + // a SEPARATE leased renderer/controller pair, same split reasoning as + // the Appearance preview fields immediately above. + ChargenPreviewRenderer? SummaryPreviewRenderer, + ChargenPreviewController? SummaryPreviewController, WbFrustum EnvCellFrustum, EnvCellRenderer? EnvCellRenderer, LandblockPresentationPipeline LandblockPipeline, @@ -1102,6 +1107,82 @@ internal sealed class LivePresentationCompositionPhase + "time — the Appearance page's zoom/rotate controls and " + "3D preview will not function this session."); } + + // Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance + // (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE + // instance from the Appearance page's own during the CC6b-MOUNT + // review) — same one-shot binding shape as the Appearance preview + // immediately above (AP-221's own disposition applies here too: a + // DAT/resource read not ready on this exact composition frame means + // the Summary preview stays permanently unbound for the session, + // same tracked follow-up as the Appearance preview). No zoom/rotate + // control surface is wired — retail's Summary page has no such + // buttons (only StartAnimation's idle loop and a fixed 180° + // heading), so this controller's ZoomIn/RotateClockwise etc. simply + // never get called. + CompositionAcquisitionScope.CompositionAcquisitionLease< + ChargenPreviewRenderer>? summaryPreviewLease = null; + ChargenPreviewController? summaryPreviewController = null; + if (dispatcherLease.Resource is { } summaryDispatcher + && interaction.RetainedUi?.Runtime.SummaryPreviewViewportWidget is { } summaryViewport) + { + var summaryCamera = new ChargenPreviewCamera(); + summaryPreviewLease = scope.Acquire( + "summary preview viewport", + () => new ChargenPreviewRenderer( + worldPassScope + ?? throw new InvalidOperationException( + "The graphics backend must publish a world pass scope."), + host.GpuDevice, + host.GpuFrameLifetime, + summaryDispatcher, + foundation.SceneLighting!, + foundation.TextureCache, + foundation.MeshAdapter!, + camera: summaryCamera), + static value => value.Dispose()); + IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer; + summaryViewport.Renderer = summaryPreviewLease.Resource; + bindings.AdoptRelease( + "summary preview viewport target", + () => + { + if (ReferenceEquals(summaryViewport.Renderer, summaryPreviewLease.Resource)) + summaryViewport.Renderer = previousSummaryRenderer; + }); + + var summaryCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats); + summaryPreviewController = new ChargenPreviewController( + summaryPreviewLease.Resource, + summaryCamera, + new RetailChargenPreviewFrameView( + summaryViewport, + new RetailSummaryPreviewPageVisibility(interaction.RetainedUi.Runtime)), + content.Dats, + content.AnimationLoader, + summaryCatalog, + summaryCatalog, + d.DatLock); + interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController; + bindings.AdoptRelease( + "summary preview control", + () => + { + if (ReferenceEquals( + interaction.RetainedUi.Runtime.SummaryPreviewControl, + summaryPreviewController)) + { + interaction.RetainedUi.Runtime.SummaryPreviewControl = null; + } + }); + } + else if (dispatcherLease.Resource is not null && interaction.RetainedUi is not null) + { + Console.WriteLine( + "[UI] summary preview viewport unavailable at composition " + + "time — the Summary page's 3D preview will not function " + + "this session."); + } Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); @@ -1410,6 +1491,8 @@ internal sealed class LivePresentationCompositionPhase creatureAppraisalPresenter, chargenPreviewLease?.Resource, chargenPreviewController, + summaryPreviewLease?.Resource, + summaryPreviewController, envCellFrustum, envCellLease.Resource, landblockPipeline, diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index 94998b47..a0c16d53 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -78,6 +78,19 @@ internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVi public bool IsVisible => _runtime.IsChargenPreviewPageVisible; } +/// Campaign CC slice CC5: the Summary page's own visibility gate — +/// same shape as , reading +/// RetailUiRuntime.IsSummaryPreviewPageVisible instead. +internal sealed class RetailSummaryPreviewPageVisibility : IChargenPreviewPageVisibility +{ + private readonly AcDream.App.UI.RetailUiRuntime _runtime; + + public RetailSummaryPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public bool IsVisible => _runtime.IsSummaryPreviewPageVisible; +} + /// Retained-UI visibility + texture publication, mirroring /// RetailPaperdollFrameView. internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7fad2da9..4a0bc6aa 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -434,6 +434,10 @@ public sealed class GameWindow : // viewports above. private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer; private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController; + // Campaign CC slice CC5: the Summary page's own gmCG3DView instance — + // a SEPARATE renderer/controller pair from the Appearance preview above. + private AcDream.App.Rendering.ChargenPreviewRenderer? _summaryPreviewRenderer; + private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession; @@ -1123,6 +1127,8 @@ public sealed class GameWindow : _creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter; _chargenPreviewRenderer = result.ChargenPreviewRenderer; _chargenPreviewController = result.ChargenPreviewController; + _summaryPreviewRenderer = result.SummaryPreviewRenderer; + _summaryPreviewController = result.SummaryPreviewController; _envCellFrustum = result.EnvCellFrustum; _envCellRenderer = result.EnvCellRenderer; _landblockPresentationPipeline = result.LandblockPipeline; @@ -1770,6 +1776,8 @@ public sealed class GameWindow : _creatureAppraisalViewportRenderer, _chargenPreviewRenderer, _chargenPreviewController, + _summaryPreviewRenderer, + _summaryPreviewController, _wbDrawDispatcher, _envCellRenderer, _portalDepthMask, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index ca95a57e..55c27921 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -114,6 +114,14 @@ internal sealed record RenderShutdownRoots( CreatureAppraisalViewportRenderer? CreatureAppraisal, ChargenPreviewRenderer? ChargenPreview, ChargenPreviewController? ChargenPreviewController, + // Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance — + // same guard/shutdown shape as the Appearance-page preview above (a + // SEPARATE renderer/controller pair, not a shared one — retail's own + // gmCGSummaryPage::InitializePage @0x0047bbf0 constructs its own + // gmCG3DView, confirmed a distinct instance from the Appearance page's + // during the CC6b-MOUNT review). + ChargenPreviewRenderer? SummaryPreview, + ChargenPreviewController? SummaryPreviewController, WbDrawDispatcher? DrawDispatcher, EnvCellRenderer? EnvironmentCells, PortalDepthMaskRenderer? PortalDepthMask, @@ -493,6 +501,8 @@ internal static class GameWindowShutdownManifest () => render.CreatureAppraisal?.Dispose()), Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()), Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()), + Hard("summary preview control", () => render.SummaryPreviewController?.Dispose()), + Hard("summary preview viewport", () => render.SummaryPreview?.Dispose()), Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), Hard("environment cells", () => render.EnvironmentCells?.Dispose()), Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index cf6b8d14..76ed43e5 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -544,6 +544,21 @@ internal sealed class CurrentGameRuntimeAdapter RuntimeGenerationToken expectedGeneration) => owner.ExecuteCharacterCreation( commands => commands.AcknowledgeRejection(expectedGeneration)); + + public RuntimeCommandResult RandomizeCharacter( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeCharacter(expectedGeneration)); + + public RuntimeCommandResult RandomizeAppearance( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeAppearance(expectedGeneration)); + + public RuntimeCommandResult RandomizeClothing( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeClothing(expectedGeneration)); } private sealed class AdapterCharacterCreationObserver( diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index ec3907be..070cd131 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -282,6 +282,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable RebuildPreview(view, snapshot); } + /// + /// Campaign CC slice CC5: ports the Appearance case of + /// gmCharGenMainUI::DoRandom @ 0x004e7d70 (case 3) — + /// m_eCurType == ECG_CHOICE_CLOTHES -> + /// CharGenState::RandomizeClothing(state, 1), else + /// CharGenState::RandomizeAppearance(state, 0). Retires the + /// Appearance half of register AP-212 (the primitives are now real, + /// faithful ports — see RuntimeCharacterCreationState's own + /// Randomize section — not a uniform-pick approximation). + /// + internal void Randomize() + { + if (_disposed) + return; + if (_currentChoice == Choice.Clothes) + _bindings.RandomizeClothing?.Invoke(); + else + _bindings.RandomizeAppearance?.Invoke(); + } + // ── Gender / Face-Clothes sub-tab ────────────────────────────────── private void SelectChoice(Choice choice) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs new file mode 100644 index 00000000..d95dfdc8 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -0,0 +1,372 @@ +using System.Globalization; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Summary page (gmCGSummaryPage, root 0x100003d6) — +/// Campaign CC slice CC5, retiring the TS-82 content-inert placeholder. +/// Decomp anchors: gmCGSummaryPage::InitializePage @ 0x0047bbf0 +/// (widget ids, its OWN gmCG3DView instance, camera set + 180° +/// heading + StartAnimation — a live idle-animated preview, not a +/// static frozen frame), ::SetSummaryText @ 0x0047b1d0 (the listbox's +/// three-template row content: template 0 = a single UiText line, +/// template 1 = a category-header UiText, template 2 = a two-column +/// key/value UiText pair — live-DAT-probe-confirmed against the +/// installed EoR dat, resolving DID 0x2100004C elements +/// 0x100002F8/FA/FB), ::ListenToElementMessage @ 0x0047bf40 +/// (the name field's commit-on-idMessage-0x12-or-0x44 dispatch, the +/// >32-char ID_CharGen_NameTooLong reject-and-revert path — see +/// 's own doc comment for the 32-vs-33 +/// reconciliation), ::DoNameLimitDialog @ 0x0047bd80. +/// +/// +/// Listbox content scope cut (register-worthy, AP-213's own precedent): +/// retail's skills section walks FOUR buckets (Specialized/Trained/ +/// UseableUntrained/UnuseableUntrained) and lists every skill name in each. +/// This port lists Specialized and Trained only — the two buckets a player +/// actually spent credits on and would review before Finishing — and skips +/// the two Untrained buckets (which would otherwise list the ~50 skills the +/// player did NOT touch, adding volume without decision-relevant +/// information). Health/Stamina/Mana reuse +/// 's own already-cited +/// UpdateAttributeValues @ 0x00482450 formulas (Health=Endurance/2, +/// Stamina=Endurance, Mana=Self) rather than this page's OWN +/// SetSummaryText call site, whose two GetAttribute calls for +/// Health/Stamina are decompiler-ambiguous (both show a literal attribute +/// index of 2 — ProfessionPage's site is the cleaner citation). +/// +/// +internal sealed class CharacterCreationSummaryPage : IDisposable +{ + internal const uint ListBoxId = 0x10000400u; + internal const uint ScrollId = 0x10000401u; + internal const uint NameTextId = 0x10000402u; + internal const uint HowToTextId = 0x10000404u; + internal const uint ViewportId = 0x10000406u; + + /// Row-template child ids, live-DAT-probe-confirmed: + /// template 0's single line, template 1's header line, template 2's + /// key/value pair. + private const uint SingleLineTextId = 0x100002F9u; + private const uint HeaderTextId = 0x100000FEu; + private const uint KeyTextId = 0x100002FCu; + private const uint ValueTextId = 0x100002FDu; + + /// Retail's name[33] buffer (32 usable chars + null + /// terminator — RuntimeCharacterCreationState.TrySetName's own + /// already-established storage cap). The decompiled UI-side check at + /// ListenToElementMessage @ 0x0047bfd1 compares the raw input + /// length against the literal 0x21 (33) — one more than this — + /// but that comparison's exact base (visible character count vs. an + /// internal length-prefix accounting the decompiler didn't resolve + /// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps + /// the UI-level reject-and-revert threshold CONSISTENT with the + /// already-reviewed storage cap rather than trusting an ambiguous + /// 1-off decomp literal over that established contract. + private const int MaxNameLength = 32; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly RetailDialogFactory _dialogs; + private readonly string _nameTooLongMessage; + private readonly UiTemplateListBox? _list; + private readonly UiField? _nameField; + private string _lastCommittedName = string.Empty; + private bool _suppressNextFieldEvent; + private uint _nameTooLongDialogContext; + private bool _disposed; + + /// Late-bound preview control seam — see + /// 's own doc comment for why this + /// page cannot receive the real renderer at construction time. + internal IChargenPreviewControl? PreviewControl { get; set; } + + /// The authored viewport (0x10000406) — Summary's OWN + /// gmCG3DView instance, distinct from the Appearance page's. + internal UiViewport? Viewport { get; } + + internal CharacterCreationSummaryPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings, + RetailDialogFactory dialogs, + string nameTooLongMessage) + { + _bindings = bindings; + _dialogs = dialogs; + _nameTooLongMessage = nameTooLongMessage; + + _list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox; + + _nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField; + if (_nameField is not null) + { + // NameInputFilter @ 0x004663b0: ASCII letters, space, apostrophe, + // hyphen — everything else is rejected per keystroke. + _nameField.CharacterFilter = NameInputFilter; + // Deliberately NOT capping UiField.MaxCharacters at MaxNameLength: + // retail's own >32-char check (ListenToElementMessage's own + // GetText().m_charbuffer length compare) only fires at COMMIT + // time (idMessage 0x12/0x44), which means the textbox itself + // accepts MORE than 32 characters while typing — the + // DoNameLimitDialog reject-and-revert path exists specifically + // to catch that post-typing case. A per-keystroke cap here would + // make that whole retail code path structurally unreachable. + // ListenToElementMessage @ 0x0047bf50: the name field commits on + // idMessage 0x12 OR 0x44 — acdream's UiField exposes those two + // triggers as OnFocusLost (clicking/tabbing away) and OnSubmit + // (Enter). Both route through the same commit path. + _nameField.OnFocusLost = CommitNameFromField; + _nameField.OnSubmit = CommitNameFromField; + _nameField.ClearOnSubmit = false; + _nameField.RecordHistory = false; + } + + Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_disposed) + return; + + // Keep the field's displayed text in sync with the committed name + // unless the player is actively typing (a mid-edit Refresh — driven + // by an unrelated selection change elsewhere on the screen — must + // not clobber their in-progress keystrokes). + if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name) + { + _suppressNextFieldEvent = true; + field.SetText(snapshot.Name); + _lastCommittedName = snapshot.Name; + } + + RebuildListbox(view, snapshot); + RebuildPreview(view, snapshot); + } + + // ── Name field (ListenToElementMessage @ 0x0047bf40) ──────────────── + + private void CommitNameFromField(string text) + { + if (_disposed) + return; + if (_suppressNextFieldEvent) + { + _suppressNextFieldEvent = false; + return; + } + + if (text.Length > MaxNameLength) + { + // DoNameLimitDialog @ 0x0047bd80 (ID_CharGen_NameTooLong): + // revert the field to the last COMMITTED name rather than the + // rejected input. + _nameField?.SetText(_lastCommittedName); + ShowNameTooLongDialog(); + return; + } + + _lastCommittedName = text; + _bindings.SetName?.Invoke(text); + } + + private void ShowNameTooLongDialog() + { + // DoNameLimitDialog's own guard: a context already open is a no-op. + if (_nameTooLongDialogContext != 0u) + return; + _nameTooLongDialogContext = _dialogs.MakeMessage( + _nameTooLongMessage, + data => + { + _ = data; + _nameTooLongDialogContext = 0u; + }); + } + + /// Ports NameInputFilter @ 0x004663b0 exactly: ASCII + /// letters (isalpha), space (0x20), apostrophe + /// (0x27), or hyphen (0x2d). + private static bool NameInputFilter(char c) => + (c < 0x100 && char.IsAsciiLetter(c)) || c is ' ' or '\'' or '-'; + + // ── Listbox (SetSummaryText @ 0x0047b1d0) ─────────────────────────── + + private void RebuildListbox( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_list is null || _list.Templates.Count < 3) + return; + + _list.Flush(); + + if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage)) + return; + + UiTemplateListEntry lineTemplate = _list.Templates[0]; + UiTemplateListEntry headerTemplate = _list.Templates[1]; + UiTemplateListEntry pairTemplate = _list.Templates[2]; + + AddLine(lineTemplate, "Profession: " + ProfessionName(heritage, snapshot.Template)); + AddLine(lineTemplate, "Gender: " + GenderName(heritage, snapshot.GenderKey)); + AddLine(lineTemplate, "Heritage: " + heritage.Name); + AddLine(lineTemplate, "Starting Town: " + StarterAreaName(view.Options, snapshot.StartArea)); + + AddHeader(headerTemplate, "Attributes"); + ChargenAttributeValues a = snapshot.Attributes; + AddPair(pairTemplate, "Strength", a.Strength); + AddPair(pairTemplate, "Endurance", a.Endurance); + AddPair(pairTemplate, "Coordination", a.Coordination); + AddPair(pairTemplate, "Quickness", a.Quickness); + AddPair(pairTemplate, "Focus", a.Focus); + AddPair(pairTemplate, "Self", a.Self); + // CharacterCreationProfessionPage::Refresh's own already-cited + // UpdateAttributeValues formulas (Health=Endurance/2, Stamina= + // Endurance, Mana=Self) — see this class's own doc comment on why + // that citation is used here instead of this page's own + // decompiler-ambiguous GetAttribute(2)/GetAttribute(2) pair. + AddPair(pairTemplate, "Health", a.Endurance / 2); + AddPair(pairTemplate, "Stamina", a.Endurance); + AddPair(pairTemplate, "Mana", a.Self); + AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits); + + AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized); + AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained); + } + + private void AddLine(UiTemplateListEntry template, string text) + { + if (ResolveTemplateChild(template, SingleLineTextId) is { } child) + SetLine(child, text); + } + + private void AddHeader(UiTemplateListEntry template, string text) + { + if (ResolveTemplateChild(template, HeaderTextId) is { } child) + SetLine(child, text); + } + + private void AddPair(UiTemplateListEntry template, string key, int value) + { + UiElement? row = ResolveTemplateRow(template); + if (row is null) + return; + if (UiElement.FindDescendant(row, KeyTextId) is UiText keyText) + SetLine(keyText, key); + if (UiElement.FindDescendant(row, ValueTextId) is UiText valueText) + SetLine(valueText, value.ToString(CultureInfo.InvariantCulture)); + } + + private static void SetLine(UiText text, string content) => + text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; + + private UiElement? ResolveTemplateRow(UiTemplateListEntry template) + { + if (_list is null || _list.TemplateResolver is null) + return null; + UiElement? row = _list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId); + if (row is null) + return null; + _list.AddPrebuiltRow(row); + return row; + } + + private UiText? ResolveTemplateChild(UiTemplateListEntry template, uint childId) + { + UiElement? row = ResolveTemplateRow(template); + return row is null ? null : UiElement.FindDescendant(row, childId) as UiText; + } + + private void AddSkillBucket( + UiTemplateListEntry headerTemplate, + UiTemplateListEntry lineTemplate, + IRuntimeCharacterCreationView view, + string header, + ChargenSkillAdvancementClass targetClass) + { + bool any = false; + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (view.GetSkillLevel(skillId) != targetClass) + continue; + if (!any) + { + AddHeader(headerTemplate, header); + any = true; + } + AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId)); + } + } + + private static string ProfessionName(ChargenHeritageOptions heritage, uint template) => + template != RuntimeCharacterCreationSnapshot.TemplateUnset + && template < (uint)heritage.Templates.Count + ? heritage.Templates[(int)template].Name + : "None"; + + private static string GenderName(ChargenHeritageOptions heritage, uint genderKey) => + heritage.GendersByKey.TryGetValue((int)genderKey, out ChargenGenderOptions? gender) + ? gender.Name + : "None"; + + private static string StarterAreaName(ChargenOptions options, int startArea) => + startArea >= 0 && startArea < options.StarterAreas.Count + ? options.StarterAreas[startArea].Name + : "None"; + + // ── Preview (own gmCG3DView — InitializePage @0x0047bbf0, camera set + + // ── SetPlayerHeading(180) + StartAnimation, an idle-animated view) ─── + + private void RebuildPreview( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (PreviewControl is null + || snapshot.HeritageId == 0u + || snapshot.GenderKey == 0u) + { + return; + } + + RuntimeCharacterCreationAppearance a = snapshot.Appearance; + var selection = new ChargenAppearanceSelection( + a.EyesStrip, a.NoseStrip, a.MouthStrip, + a.HairStyle, a.HairColor, a.EyeColor, + a.HeadgearStyle, a.HeadgearColor, + a.ShirtStyle, a.ShirtColor, + a.TrousersStyle, a.TrousersColor, + a.FootwearStyle, a.FootwearColor, + a.SkinShade, a.HairShade, a.HeadgearShade, + a.ShirtShade, a.TrousersShade, a.FootwearShade); + + PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_nameField is not null) + { + _nameField.OnFocusLost = null; + _nameField.OnSubmit = null; + } + if (_nameTooLongDialogContext != 0u) + { + uint closing = _nameTooLongDialogContext; + _nameTooLongDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + _list?.Flush(); + // PreviewControl is owned by the composition root (disposed with + // the leased ChargenPreviewRenderer) — just drop the reference. + PreviewControl = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index 36126958..d74a0462 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -43,6 +44,23 @@ public sealed record CharacterCreationRuntimeBindings( /// degrades to the heritage's own DAT /// Name field instead of the full composed copy. Func? ResolveText = null, + /// Campaign CC slice CC5: the Summary page's name field + /// commit (gmCGSummaryPage::ListenToElementMessage's + /// CharGenState::SetName call). + Func? SetName = null, + /// CC5: dismisses a surfaced 0xF643 rejection after its + /// dialog closes (RuntimeCharacterCreationState.TryAcknowledgeRejection). + Func? AcknowledgeRejection = null, + /// CC5: the screen-open roll + /// (gmCharGenMainUI's ctor-time RandomizeCharacter call) + /// and the Summary page's Random button. + Func? RandomizeCharacter = null, + /// CC5: the Appearance page's Random button on its Face + /// sub-tab. + Func? RandomizeAppearance = null, + /// CC5: the Appearance page's Random button on its Clothes + /// sub-tab. + Func? RandomizeClothing = null, bool OpenOnStart = false); /// @@ -106,7 +124,23 @@ internal sealed class CharacterCreationUiController : IDisposable Summary = 6, } - internal sealed record DialogStrings(string ExitWarning); + internal sealed record DialogStrings( + string ExitWarning, + /// Campaign CC slice CC5: ID_CharGen_NoNameWarning — + /// DoFinish's empty-name refusal dialog. + string NoNameWarning, + /// CC5: ID_CharGen_CreditWarning — + /// MakeCreditWarningDialog's unspent-attribute-credits + /// confirmation. + string CreditWarning, + /// CC5: ID_CharGen_RandomizeWarning — + /// MakeRandomizeWarningDialog's Summary-page Random + /// confirmation. + string RandomizeWarning, + /// CC5: ID_CharGen_NameTooLong — + /// gmCGSummaryPage::DoNameLimitDialog's name-field-too-long + /// notice. + string NameTooLong); private readonly UiRoot _host; private readonly ImportedLayout _layout; @@ -138,6 +172,7 @@ internal sealed class CharacterCreationUiController : IDisposable private readonly CharacterCreationSkillsPage _skillsPage; private readonly CharacterCreationTownPage _townPage; private readonly CharacterCreationAppearancePage _appearancePage; + private readonly CharacterCreationSummaryPage _summaryPage; private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; @@ -147,6 +182,13 @@ internal sealed class CharacterCreationUiController : IDisposable private bool _isOpen; private bool _openOnStartConsumed; private uint _exitDialogContext; + // Campaign CC slice CC5: gmCharGenMainUI's own m_uiCreditWarningContext/ + // m_uiRandomizeWarningContext (0x004e8870/0x004e8a90) — same + // one-outstanding-dialog-at-a-time guard shape as _exitDialogContext. + private uint _creditWarningDialogContext; + private uint _randomizeWarningDialogContext; + private uint _noNameWarningDialogContext; + private RuntimeCharacterCreationRejection? _lastShownRejection; private bool _suppressDialogCallbacks; private bool _disposed; @@ -227,18 +269,18 @@ internal sealed class CharacterCreationUiController : IDisposable _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); _townPage = new CharacterCreationTownPage(townPageRoot, bindings); _appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings); + _summaryPage = new CharacterCreationSummaryPage( + summaryPageRoot, bindings, dialogs, strings.NameTooLong); // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. _back.OnClick = OnBack; _next.OnClick = OnNext; - // Finish (0x100003c8) stays ghosted this round: Summary - // (0x100003d6) is CC5's placeholder, and DoFinish's real gate - // sequence lives in RuntimeCharacterCreationState.TryBeginFinish — - // wiring the button here without a Summary page to confirm/collect - // the name would let a click reach the wire with an empty name and - // silently refuse. No OnClick handler; _finish.Enabled stays false - // (see ApplyProgressState). - _finish.OnClick = null; + // Finish (0x100003c8): retail enables it on Summary only + // (ListenToElementMessage's case 0x100003c8 no-ops unless + // m_eProgressState == ECG_SUMMARY @ 0x004e956f) — ApplyProgressState + // gates _finish.Enabled the same way. OnFinish itself re-checks the + // current page defensively (mirroring that same retail guard). + _finish.OnClick = OnFinish; // Help (0x100003c9) is not handled in gmCharGenMainUI's own // ListenToElementMessage switch (case 0x100003c9 falls straight // through to the base UIFramework handler) — retail has no custom @@ -280,6 +322,26 @@ internal sealed class CharacterCreationUiController : IDisposable /// paperdoll's own outer-inventory-frame gate. internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible; + /// Campaign CC slice CC5: the authored Summary-page viewport + /// (0x10000406) — its OWN gmCG3DView instance, distinct + /// from the Appearance page's (see this class's own class doc on the + /// decomp citation). + internal UiViewport? SummaryViewport => _summaryPage.Viewport; + + /// CC5: the Summary preview's late-bound control surface. No + /// zoom/rotate buttons bind against it — see + /// 's + /// doc comment. + internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl + { + get => _summaryPage.PreviewControl; + set => _summaryPage.PreviewControl = value; + } + + /// CC5: same shape as , + /// for the Summary page. + internal bool IsSummaryPageVisible => Root.Visible && _summaryPageRoot.Visible; + internal static CharacterCreationUiController? CreateDetached( UiRoot host, ImportedLayout layout, @@ -404,6 +466,7 @@ internal sealed class CharacterCreationUiController : IDisposable _skillsPage.Refresh(view, snapshot); _townPage.Refresh(view, snapshot); _appearancePage.Refresh(view, snapshot); + _summaryPage.Refresh(view, snapshot); _lastGeneration = snapshot.Generation; _lastRevision = snapshot.Revision; } @@ -426,9 +489,40 @@ internal sealed class CharacterCreationUiController : IDisposable return; _isOpen = true; _host.DeclareFixedCanvas(this, _authoredCanvas); + RollOpeningCharacter(); ApplyProgressState(Page.Heritage); } + /// + /// Campaign CC slice CC5: ports gmCharGenMainUI's ctor-time roll + /// (~0x004e81f5-0x004e8218) — CharGenState::RandomizeCharacter + /// (state, hasToD) @ 0x005c6d80 runs BEFORE any page constructs, + /// retiring AP-214's honest-blank deviation (retail's chargen screen is + /// never actually blank on open). Then reproduces + /// gmCGAppearancePage::InitializePage's own gender-read-then-FLIP + /// (~0x004802da-0x00480303, decomp-confirmed: + /// mGender==1 -> SetGender(2), mGender==2 -> SetGender(1)) — + /// a genuine, always-firing retail quirk that runs immediately AFTER + /// RandomizeCharacter already assigned a real (non-zero) gender. + /// Retail's whole UI tree (every page, including Appearance) is + /// reconstructed fresh each time the chargen screen opens, so the flip + /// fires once per visit there; acdream's pages are built once at mount + /// time and only toggle visibility, so — the closest + /// analogue to "runs once per screen-open" this architecture has — is + /// where both the roll and the flip belong. + /// + private void RollOpeningCharacter() + { + if (_bindings.RandomizeCharacter?.Invoke().Status != RuntimeCommandStatus.Accepted) + return; + + uint gender = _bindings.View()?.Snapshot.GenderKey ?? 0u; + if (gender == 1u) + _bindings.SelectGender(2u); + else if (gender == 2u) + _bindings.SelectGender(1u); + } + private void Close() { if (!_isOpen) @@ -472,6 +566,7 @@ internal sealed class CharacterCreationUiController : IDisposable _skillsPage.Dispose(); _townPage.Dispose(); _appearancePage.Dispose(); + _summaryPage.Dispose(); _host.RemoveChild(Root); } } @@ -535,10 +630,16 @@ internal sealed class CharacterCreationUiController : IDisposable return; // gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town - // are ported below; Skills' CharGenState::RandomizeSkills and the - // Summary randomize-warning dialog have no CC3 primitive/page yet - // this round — register AP-212 covers both gaps, and _random.Enabled - // already keeps the control ghosted on those pages (ApplyProgressState). + // still use the AP-212 uniform-pick approximation (unchanged this + // slice); Appearance now delegates to the page's own real + // RandomizeAppearance/RandomizeClothing primitives (CC5); Skills' + // CharGenState::RandomizeSkills remains unported (AP-212, narrowed) — + // _random.Enabled already keeps the control ghosted there + // (ApplyProgressState). Summary goes through + // gmCharGenMainUI::MakeRandomizeWarningDialog @ 0x004e8a90 first — + // that dialog + its confirm-triggered RandomizeCharacter call are + // gmCharGenMainUI's OWN methods in retail (not gmCGSummaryPage's), + // so they live here on the master controller. IRuntimeCharacterCreationView? view = _bindings.View(); if (view is null) return; @@ -552,12 +653,44 @@ internal sealed class CharacterCreationUiController : IDisposable case Page.Profession: _professionPage.Randomize(snapshot); break; + case Page.Appearance: + _appearancePage.Randomize(); + break; case Page.Town: _townPage.Randomize(view); break; + case Page.Summary: + ShowRandomizeWarningDialog(); + break; } } + /// Ports gmCharGenMainUI::MakeRandomizeWarningDialog @ + /// 0x004e8a90 (ID_CharGen_RandomizeWarning) + + /// CloseRandomizeWarningDialog @ 0x004e8400's own confirm arm + /// (arg2 != 0 -> DoRandom(this), which on THIS second call + /// takes DoRandom's Summary case directly — no re-entrant + /// warning, since the gate lives in the button-click dispatcher above, + /// not inside DoRandom itself). + private void ShowRandomizeWarningDialog() + { + // MakeRandomizeWarningDialog's own guard: a second click while the + // dialog is already open is a no-op. + if (_randomizeWarningDialogContext != 0u) + return; + + _randomizeWarningDialogContext = _dialogs.MakeConfirmation( + _strings.RandomizeWarning, + data => + { + _randomizeWarningDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + _bindings.RandomizeCharacter?.Invoke(); + }); + } + // ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ──── private void ApplyProgressState(Page target) @@ -641,19 +774,14 @@ internal sealed class CharacterCreationUiController : IDisposable break; } - // Random (0x100003cb): fix round F5 — retail's DoRandom @0x004e7d70 - // case 3 fully ENABLES Random on Appearance (RandomizeClothing when - // m_eCurType == ECG_CHOICE_CLOTHES, else RandomizeAppearance); this - // is NOT a placeholder gap the way the old comment claimed. The - // disable here rests on the SAME unported-primitive gap AP-212 - // tracks for Skills (no RandomizeSkills) and Summary (no - // RandomizeCharacter) — RandomizeAppearance/RandomizeClothing are - // two more of AP-212's six named-but-unported primitives. - _random.Enabled = _currentPage - is not (Page.Skills or Page.Appearance or Page.Summary); - // Finish stays ghosted regardless of page — Summary is a - // placeholder this round (see the ctor comment on _finish.OnClick). - _finish.Enabled = false; + // Random (0x100003cb): CC5 ports RandomizeAppearance/RandomizeClothing + // (Appearance) and RandomizeCharacter (Summary), retiring both gaps + // AP-212 used to track for those two pages — only Skills' + // RandomizeSkills remains unported (AP-212, narrowed). + _random.Enabled = _currentPage is not Page.Skills; + // Finish (0x100003c8): retail enables it on Summary only + // (ListenToElementMessage's case 0x100003c8 no-ops off Summary). + _finish.Enabled = _currentPage == Page.Summary; _lastRevision = long.MinValue; Tick(); @@ -719,12 +847,130 @@ internal sealed class CharacterCreationUiController : IDisposable // Else (including Lugian, 0x100005f1): no-op, matching retail. } + // ── Finish (gmCharGenMainUI::DoFinish @ 0x004E9170) ───────────────── + + /// The Finish button's ordinary click — retail's arg2 = 1 + /// call site (0x004E9579). Re-checks the current page defensively, + /// mirroring ListenToElementMessage's own + /// m_eProgressState != ECG_SUMMARY no-op guard. + private void OnFinish() + { + if (_disposed || _currentPage != Page.Summary) + return; + TryFinish(confirmedUnspentCredits: false); + } + + /// + /// Sends via + /// (which itself calls RuntimeCharacterCreationState.TryBeginFinish); + /// on a LOCAL refusal, surfaces retail's own dialog for the two refusal + /// reasons retail dialogs at all (NoName -> + /// ID_CharGen_NoNameWarning; AttributeCreditsUnspent -> + /// the credit-warning confirm, whose OWN confirm re-invokes this method + /// with — retail's + /// arg2 == 0 call site, 0x004E98BB). The remaining local + /// refusals (HeritageOrGenderUnset, AlreadyPending, + /// RosterFull) have no retail dialog citation — retail's own + /// DoFinish silently falls through to its final return 0 + /// for an already-Pending double-click, and the other two are + /// acdream-only additions (register AP-223, AP-211) with the same + /// silent-refusal shape. + /// + private void TryFinish(bool confirmedUnspentCredits) + { + if (_bindings.Finish(confirmedUnspentCredits).Status != RuntimeCommandStatus.Rejected) + return; + + RuntimeCharacterCreationLocalRefusal refusal = + _bindings.View()?.Snapshot.LastLocalRefusal ?? default; + if (refusal.NoName) + ShowNoNameWarningDialog(); + else if (refusal.AttributeCreditsUnspent) + ShowCreditWarningDialog(); + } + + /// Ports the empty-name half of DoFinish + /// (ID_CharGen_NoNameWarning, @0x004e91dd) — a plain + /// informational dialog, no confirm/cancel semantics. + private void ShowNoNameWarningDialog() + { + if (_noNameWarningDialogContext != 0u) + return; + _noNameWarningDialogContext = _dialogs.MakeMessage( + _strings.NoNameWarning, + data => + { + _ = data; + _noNameWarningDialogContext = 0u; + }); + } + + /// Ports gmCharGenMainUI::MakeCreditWarningDialog @ + /// 0x004e8870 (ID_CharGen_CreditWarning) — on confirm, + /// re-invokes with + /// confirmedUnspentCredits: true, retail's DoFinish(this, 0) + /// call at RecvNotice_CloseDialog @0x004e98bb. + private void ShowCreditWarningDialog() + { + if (_creditWarningDialogContext != 0u) + return; + _creditWarningDialogContext = _dialogs.MakeConfirmation( + _strings.CreditWarning, + data => + { + _creditWarningDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + TryFinish(confirmedUnspentCredits: true); + }); + } + + // ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ── + // ── 0x0055E8B0) ────────────────────────────────────────────────────── + + /// Ports the four rejection-dialog mappings from + /// Handle_CharGenVerificationResponse's per-case switch (restated + /// on 's own doc + /// comment); Pending/Undef never reach this method (CC3's + /// ApplyCreationResponse treats them as a silent state reset with + /// no produced at all). + /// Dedups against the LAST rejection instance already shown so a + /// same-value re-check on a later (this method runs + /// every tick, not just on revision change) doesn't reopen the dialog + /// the player already dismissed. private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot) { - // Local-refusal / rejection surfacing is CC5's Summary-page job - // (the Finish gate only fires from that page). This round only - // needs the exit-confirmation dialog reconciled against disposal. - _ = snapshot; + RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection; + if (rejection is null) + { + _lastShownRejection = null; + return; + } + if (_lastShownRejection == rejection) + return; + _lastShownRejection = rejection; + + string? key = rejection.Value.Code switch + { + CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved", + CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned", + CharGenVerificationResponse.Code.Corrupt + or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown", + CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied", + _ => null, + }; + if (key is null) + return; + string? message = _bindings.ResolveText?.Invoke(key); + if (message is null) + return; + + _dialogs.MakeMessage(message, data => + { + _ = data; + _bindings.AcknowledgeRejection?.Invoke(); + }); } private void Deactivate() @@ -750,6 +996,24 @@ internal sealed class CharacterCreationUiController : IDisposable _exitDialogContext = 0u; _dialogs.CloseDialog(closing); } + if (_creditWarningDialogContext != 0u) + { + uint closing = _creditWarningDialogContext; + _creditWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_randomizeWarningDialogContext != 0u) + { + uint closing = _randomizeWarningDialogContext; + _randomizeWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_noNameWarningDialogContext != 0u) + { + uint closing = _noNameWarningDialogContext; + _noNameWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } } finally { diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index dfaab47c..22f7a199 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -669,6 +669,34 @@ public sealed class RetailUiRuntime : IDisposable internal bool IsChargenPreviewPageVisible => CharacterCreationController?.IsAppearancePageVisible ?? false; + /// Campaign CC slice CC5: the Summary page's OWN authored + /// viewport (0x10000406) — same one-shot GPU-composition + /// disposition as (see that + /// property's own doc comment; AP-221 covers both). + internal UiViewport? SummaryPreviewViewportWidget => + CharacterCreationController?.SummaryViewport; + + /// Campaign CC slice CC5: the Summary preview's late-bound + /// control surface. No zoom/rotate buttons bind against it (retail's + /// Summary page has none) — the composition root assigns it purely so + /// + /// gets driven per-selection-change the same way the Appearance + /// preview's is. + internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl + { + get => CharacterCreationController?.SummaryPreviewControl; + set + { + if (CharacterCreationController is { } controller) + controller.SummaryPreviewControl = value; + } + } + + /// Campaign CC slice CC5: whether the Summary page + /// (specifically) is the one currently showing. + internal bool IsSummaryPreviewPageVisible => + CharacterCreationController?.IsSummaryPageVisible ?? false; + public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); @@ -3977,14 +4005,38 @@ public sealed class RetailUiRuntime : IDisposable } string? exitWarning; + string? noNameWarning; + string? creditWarning; + string? randomizeWarning; + string? nameTooLong; lock (_bindings.Assets.DatLock) { exitWarning = ResolveCharacterManagementString( strings, stringTableId, "ID_CharGen_ExitWarning"); + noNameWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_NoNameWarning"); + creditWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_CreditWarning"); + randomizeWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_RandomizeWarning"); + nameTooLong = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_NameTooLong"); } - if (exitWarning is null) + if (exitWarning is null + || noNameWarning is null + || creditWarning is null + || randomizeWarning is null + || nameTooLong is null) { Console.WriteLine( "[UI] character creation: required retail strings are unavailable."); @@ -4009,7 +4061,8 @@ public sealed class RetailUiRuntime : IDisposable layoutId, layout, ResolveTemplate, - new CharacterCreationUiController.DialogStrings(exitWarning)); + new CharacterCreationUiController.DialogStrings( + exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong)); } private void MountItemCooldowns() diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index 1b34e5b8..cef6e78d 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -460,6 +460,30 @@ public interface IRuntimeCharacterCreationCommands RuntimeCommandResult AcknowledgeRejection( RuntimeGenerationToken expectedGeneration); + + // ── Campaign CC slice CC5: RandomizeCharacter port ────────────────── + + /// Retail's ctor-time CharGenState::RandomizeCharacter + /// roll (mirrored at the App layer's screen-open edge) and the Summary + /// page's Random button (gmCharGenMainUI::DoRandom case 5, behind + /// the caller's own ID_CharGen_RandomizeWarning confirmation) — + /// see . + RuntimeCommandResult RandomizeCharacter( + RuntimeGenerationToken expectedGeneration); + + /// The Appearance page's Random button while its Face sub-tab + /// is showing (gmCharGenMainUI::DoRandom case 3's else + /// arm) — see + /// . + RuntimeCommandResult RandomizeAppearance( + RuntimeGenerationToken expectedGeneration); + + /// The Appearance page's Random button while its Clothes + /// sub-tab is showing (gmCharGenMainUI::DoRandom case 3's + /// RandomizeClothing(state, 1) arm) — see + /// . + RuntimeCommandResult RandomizeClothing( + RuntimeGenerationToken expectedGeneration); } public interface IGameRuntimeCommands diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 244c6a7f..12a49065 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -1657,6 +1657,45 @@ public sealed class LiveSessionController } } + public RuntimeCommandResult RandomizeCharacter( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryRandomizeCharacter()); + } + } + + public RuntimeCommandResult RandomizeAppearance( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryRandomizeAppearance()); + } + } + + public RuntimeCommandResult RandomizeClothing( + RuntimeGenerationToken expectedGeneration) + { + lock (_gate) + { + RuntimeCommandStatus gate = ValidateCharacterCreationCommand(expectedGeneration); + if (gate != RuntimeCommandStatus.Accepted) + return CharacterCreationResult(gate); + return CharacterCreationResult( + CharacterCreationState.TryRandomizeClothing()); + } + } + private RuntimeCommandStatus ValidateCharacterCreationCommand( RuntimeGenerationToken expectedGeneration) { diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index 05a79951..701a3bf8 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -124,16 +124,32 @@ public readonly record struct RuntimeCharacterCreationAppearance( /// evaluates before a Finish click is allowed to reach the wire, plus the /// campaign's client-side slot cap (risk item 3 — retail's UI, not /// DoFinish itself, refuses when the roster is already full versus -/// CharacterList.slotCount; ACE never checks this server-side). +/// CharacterList.slotCount; ACE never checks this server-side), plus +/// (Campaign CC slice CC5, the CC6b-MOUNT review's F12 amendment) +/// — an acdream-ONLY addition with no +/// direct DoFinish citation (register AP-223): retail's own +/// DoFinish never checks heritage/gender because it can't reach a +/// state where either is unset — gmCharGenMainUI's constructor calls +/// CharGenState::RandomizeCharacter before any page (including +/// Summary/Finish) exists, so a real heritage+gender selection is an +/// ARCHITECTURAL guarantee by the time Finish is clickable at all. Once +/// is +/// wired at the App layer's screen-open edge (mirroring that same ctor +/// call), this refusal is normally unreachable through the ordinary UI — +/// it exists as a defensive backstop for any caller (a headless bot, a +/// future direct command) that can reach Finish without that +/// open-edge roll ever having run. /// public readonly record struct RuntimeCharacterCreationLocalRefusal( bool NoName, bool AttributeCreditsUnspent, bool AlreadyPending, - bool RosterFull) + bool RosterFull, + bool HeritageOrGenderUnset = false) { public bool Any => - NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull; + NoName || AttributeCreditsUnspent || AlreadyPending || RosterFull + || HeritageOrGenderUnset; public static RuntimeCharacterCreationLocalRefusal None { get; } = default; } @@ -487,27 +503,38 @@ public sealed class RuntimeCharacterCreationState : IDisposable if (_disposed || !_active) return false; - _heritageId = heritageId; - _totalAttributeCredits = heritage.AttributeCredits; - _totalSkillCredits = heritage.SkillCredits; - _remainingSkillCredits = checked((int)heritage.SkillCredits); - - ApplyTemplateLocked(heritage); - RandomizeStartAreaLocked(heritage); - ConstrainAppearanceByGenderLocked(); - RecomputeRemainingAttributeCreditsLocked(); - // ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive - // re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through - // our own gated skill commands, but kept for parity with a - // heritage switch that leaves stale skill picks over-budget. - if (RecomputeSkillSpendLocked(heritage) < 0) - ResetSkillLevelsLocked(heritage); + SetHeritageGroupLocked(heritageId, heritage); _revision++; } Publish(RuntimeCharacterCreationDeltaKind.StateChanged); return true; } + /// + /// The locked body of CharGenState::SetHeritageGroup @ 0x005C67A0 — + /// factored out of (Campaign CC slice + /// CC5) so 's own heritage roll + /// can reuse it without re-entering . + /// + private void SetHeritageGroupLocked(uint heritageId, ChargenHeritageOptions heritage) + { + _heritageId = heritageId; + _totalAttributeCredits = heritage.AttributeCredits; + _totalSkillCredits = heritage.SkillCredits; + _remainingSkillCredits = checked((int)heritage.SkillCredits); + + ApplyTemplateLocked(heritage); + RandomizeStartAreaLocked(heritage); + ConstrainAppearanceByGenderLocked(); + RecomputeRemainingAttributeCreditsLocked(); + // ConstrainAllByHeritage's UpdateRemainingSkillCredits + defensive + // re-reset (0x005C66D2/0x005C66DD) — cheap and unreachable through + // our own gated skill commands, but kept for parity with a + // heritage switch that leaves stale skill picks over-budget. + if (RecomputeSkillSpendLocked(heritage) < 0) + ResetSkillLevelsLocked(heritage); + } + /// Ports CharGenState::SetGender @ 0x005C64A0: clamps /// every appearance index into the new gender's option-list bounds. The /// four SetXStyle(this, this->XStyle) re-invocations retail @@ -527,14 +554,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable return false; } - _genderKey = genderKey; - ConstrainAppearanceByGenderLocked(); + SetGenderLocked(genderKey); _revision++; } Publish(RuntimeCharacterCreationDeltaKind.StateChanged); return true; } + /// The locked body of CharGenState::SetGender @ + /// 0x005C64A0 — factored out of + /// (Campaign CC slice CC5) so 's + /// own gender roll can reuse it. + private void SetGenderLocked(uint genderKey) + { + _genderKey = genderKey; + ConstrainAppearanceByGenderLocked(); + } + /// /// Ports the seven Profession-page buttons, each of which calls /// CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60 — template @@ -1164,6 +1200,362 @@ public sealed class RuntimeCharacterCreationState : IDisposable : value; } + // ── Randomize (Campaign CC slice CC5) ─────────────────────────────── + // Ports CharGenState::RandomizeCharacter @ 0x005c6d80 and its six named + // sub-primitives (RandomizeHeritageGroup/RandomizeAppearance/ + // RandomizeHeadgear/RandomizeShirt/RandomizeTrousers/RandomizeFootwear/ + // RandomizeTemplate/RandomizeStartArea — register AP-212's own citation + // list). The RNG primitive both retail's own RandInt(int) and + // RandInt(int,int) overloads reduce to is decompiled verbatim at + // 0x00684400/0x00684420: RandInt(count) is a uniform pick in [0,count); + // RandInt(count,exclude) loops the same roll until it differs from + // exclude (a no-op when count<=1, matching retail's own early-return — + // ported as RandomizeIndexExcludingLocked below). CharGenState's own + // vtable (acclient.h's $A0F97670E669114D706A75D718F5A366 union — + // "GetRandomInt(this,int,int)"/"Grandom Int(this,int)") confirms + // RandomizeAppearance's vtable-indirected calls are this SAME RandInt + // pair, not a distinct algorithm. + + /// Ports Random::RollDice(int,int) @ 0x0042c5c0: returns + /// unchanged when the two bounds are equal + /// (matching retail's own arg2==arg1 fast path), otherwise a + /// uniform pick over the INCLUSIVE range + /// [min(min,max), max(min,max)]. + private int RollDiceLocked(int min, int max) + { + if (min == max) + return min; + int lo = Math.Min(min, max); + int hi = Math.Max(min, max); + return lo + _random.Next(hi - lo + 1); + } + + /// Ports RandInt(int,int) @ 0x00684420 exactly: for + /// <= 1 there is only one possible outcome, + /// so retail returns 0 immediately WITHOUT ever comparing against + /// (the guard that keeps the do/while loop + /// below from spinning forever); otherwise re-rolls uniformly in + /// [0,count) until the result differs from + /// — an outside + /// [0,count) (e.g. + /// on a freshly-'d field) can never match, + /// so the loop always exits on its first iteration and this degrades to + /// a plain uniform pick. + private uint RandomizeIndexExcludingLocked(int count, uint exclude) + { + if (count <= 1) + return 0u; + uint result; + do + { + result = (uint)_random.Next(count); + } while (result == exclude); + return result; + } + + /// Ports CharGenState::RandomizeAppearance(this, 0) @ + /// 0x005c4f10 — every real call site in the retail binary passes + /// arg2 == 0 (an exhaustive grep of every RandomizeAppearance + /// call found none with arg2 != 0), so the arg2 != 0 arm + /// (a hard-coded vtable-index-7 hair-style pick) is decompiled but dead + /// code and is not ported. Each field is only rolled when its list is + /// non-empty (retail's own per-field if (count != 0) guards); + /// skinShade/hairShade are vtable->GetRandomReal() + /// — the SAME rand()*(1/32768) uniform-[0,1) shade roll every + /// other Randomize* function below uses explicitly inline. + private void RandomizeAppearanceLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + + RuntimeCharacterCreationAppearance a = _appearance; + if (gender.EyeStrips.Count > 0) + a = a with { EyesStrip = RandomizeIndexExcludingLocked(gender.EyeStrips.Count, a.EyesStrip) }; + if (gender.NoseStrips.Count > 0) + a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) }; + if (gender.MouthStrips.Count > 0) + a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) }; + a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() }; + if (gender.HairColors.Count > 0) + a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) }; + if (gender.EyeColors.Count > 0) + a = a with { EyeColor = RandomizeIndexExcludingLocked(gender.EyeColors.Count, a.EyeColor) }; + if (gender.HairStyles.Count > 0) + a = a with { HairStyle = RandomizeIndexExcludingLocked(gender.HairStyles.Count, a.HairStyle) }; + _appearance = a; + } + + /// + /// Ports CharGenState::RandomizeHeadgear(this, arg2) @ 0x005c5e10. + /// Headgear alone gets the count+1-position Unset ring + /// (CharacterCreationAppearancePage.CycleIndex's own already-cited + /// sibling finding): false (every + /// RandomizeCharacter call site, arg2==0) rolls a plain + /// uniform RandInt(count+1); true (RandomizeClothing(state,1)'s + /// own Appearance-page Random-button case) excludes the current style + /// via RandInt(count+1, headgearStyle+1) — the +1 + /// reindexes Unset (retail's signed -1) to 0 so the + /// exclude comparison stays in [0,count]. Color uses the SAME + /// shared-ClothingColors + /// approximation (register AP-208) every other clothing slot's color + /// count already uses, not retail's own per-heritage + /// numHeadgearColors field acdream's model does not carry. + /// + private void RandomizeHeadgearLocked(bool excludeCurrent) + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + + int styleCount = gender.Headgears.Count; + if (styleCount > 0) + { + uint current = _appearance.HeadgearStyle; + int currentPlusOne = current == RuntimeCharacterCreationAppearance.Unset + ? 0 + : (int)current + 1; + int rolled = excludeCurrent + ? (int)RandomizeIndexExcludingLocked(styleCount + 1, (uint)currentPlusOne) + : _random.Next(styleCount + 1); + uint newStyle = rolled == 0 ? RuntimeCharacterCreationAppearance.Unset : (uint)(rolled - 1); + _appearance = _appearance with { HeadgearStyle = newStyle }; + } + + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.HeadgearColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor), + }; + } + _appearance = _appearance with { HeadgearShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeShirt @ 0x005c5ef0 — + /// unlike headgear, retail's shirt/trousers/footwear randomizers take no + /// arg2 and always exclude the current style/color. + private void RandomizeShirtLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + int styleCount = gender.Shirts.Count; + if (styleCount > 0) + { + _appearance = _appearance with + { + ShirtStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.ShirtStyle), + }; + } + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.ShirtColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor), + }; + } + _appearance = _appearance with { ShirtShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeTrousers @ 0x005c5fb0. + private void RandomizeTrousersLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + int styleCount = gender.Pants.Count; + if (styleCount > 0) + { + _appearance = _appearance with + { + TrousersStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.TrousersStyle), + }; + } + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.TrousersColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor), + }; + } + _appearance = _appearance with { TrousersShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeFootwear @ 0x005c6070. + private void RandomizeFootwearLocked() + { + if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) + return; + int styleCount = gender.Footwear.Count; + if (styleCount > 0) + { + _appearance = _appearance with + { + FootwearStyle = RandomizeIndexExcludingLocked(styleCount, _appearance.FootwearStyle), + }; + } + int colorCount = AppearanceSlotCountLocked(ChargenAppearanceSlot.FootwearColor, gender); + if (colorCount > 0) + { + _appearance = _appearance with + { + FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor), + }; + } + _appearance = _appearance with { FootwearShade = _random.NextDouble() }; + } + + /// Ports CharGenState::RandomizeClothing(this, arg2) @ + /// 0x005c6770: headgear (with + /// forwarded), then shirt/trousers/footwear (always exclude-current, + /// they take no arg2). + private void RandomizeClothingLocked(bool excludeCurrent) + { + RandomizeHeadgearLocked(excludeCurrent); + RandomizeShirtLocked(); + RandomizeTrousersLocked(); + RandomizeFootwearLocked(); + } + + /// + /// Ports CharGenState::RandomizeTemplate @ 0x005c6500. The two + /// Olthoi heritages force template 0 unconditionally + /// (this->template_ = 1; ApplyTemplate(this); in retail — but + /// ApplyTemplate's own Olthoi branch immediately re-forces + /// template_ = 0 regardless, so the intermediate write to 1 is + /// observably a no-op; this port skips straight to + /// , which already carries that force). + /// Otherwise, when the heritage has more than one template (Custom plus + /// at least one preset), picks RandInt(count-1, template_-1) + 1 — + /// a uniform pick over indices [1, count-1] (retail's own + /// preset templates, NEVER index 0/Custom) excluding the CURRENT + /// template (offset by -1 to align with the shifted range; an + /// Unset/0xFFFFFFFF current value wraps far outside [0,count-1) + /// and can never match, so a fresh roll off a Reset state is + /// unconstrained). + /// + private void RandomizeTemplateLocked() + { + if (_heritageId == 0 || _genderKey == 0) + return; + if (!_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? heritage)) + return; + + if (_heritageId == (uint)ChargenHeritageGroup.Olthoi + || _heritageId == (uint)ChargenHeritageGroup.OlthoiAcid) + { + ApplyTemplateLocked(heritage); + return; + } + + int count = heritage.Templates.Count; + if (count <= 1) + return; + + uint excludeShifted = unchecked(_template - 1u); + uint picked = RandomizeIndexExcludingLocked(count - 1, excludeShifted) + 1u; + _template = picked; + ApplyTemplateLocked(heritage); + } + + /// + /// Ports CharGenState::RandomizeCharacter(this, hasToD) @ + /// 0x005c6d80: (retail's own + /// Reset()), roll a heritage + /// (SetHeritageGroup(RollDice(1, hasToD?4:3)) — heritage ids + /// 1..3/4 are the four HUMAN heritage groups (Aluvian/Gharu'ndim/Sho/ + /// Viamontian, 's own numbering); a + /// "random" character in retail is deliberately always human, never one + /// of the other nine heritages — a genuine retail quirk, not a porting + /// shortcut), roll a gender (SetGender(RollDice(1,2))), then + /// appearance/headgear/shirt/trousers/footwear/template/start-area, in + /// that exact order. acdream has no account/DLC-ownership signal (AD-102's + /// already-established convention: every installed heritage/town ships + /// unconditionally selectable, matching what a ToD-owning account would + /// see) — this reuses that SAME convention rather than inventing a + /// second one, so the heritage roll always uses the 4-heritage bound. + /// SetHeritageGroupLocked already rolls a starting area once as + /// part of its own RandomizeStartAreaLocked call (mirroring + /// retail's own SetHeritageGroup); the explicit + /// RandomizeStartAreaLocked call at the end re-rolls it a SECOND + /// time, matching retail's own redundant double-roll exactly (harmless — + /// each roll is independently uniform over the same list). + /// + private void RandomizeCharacterLocked() + { + ClearSessionState(); + + uint heritageId = (uint)RollDiceLocked(1, 4); + if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + SetHeritageGroupLocked(heritageId, heritage); + + uint genderKey = (uint)RollDiceLocked(1, 2); + SetGenderLocked(genderKey); + + RandomizeAppearanceLocked(); + RandomizeHeadgearLocked(excludeCurrent: false); + RandomizeShirtLocked(); + RandomizeTrousersLocked(); + RandomizeFootwearLocked(); + RandomizeTemplateLocked(); + if (_options.TryGetHeritage(_heritageId, out ChargenHeritageOptions? finalHeritage)) + RandomizeStartAreaLocked(finalHeritage); + } + + /// Public command surface for — + /// consumed by the App layer's screen-open edge (mirrors + /// gmCharGenMainUI's ctor-time roll, retiring AP-214's + /// honest-blank deviation) and the Summary page's Random button + /// (gmCharGenMainUI::DoRandom case 5, behind the + /// ID_CharGen_RandomizeWarning confirmation the App layer + /// owns). + internal bool TryRandomizeCharacter() + { + lock (_gate) + { + if (_disposed || !_active) + return false; + RandomizeCharacterLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Public command surface for — + /// the Appearance page's Random button when its Face sub-tab is showing + /// (gmCharGenMainUI::DoRandom case 3's else arm). + internal bool TryRandomizeAppearance() + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + RandomizeAppearanceLocked(); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + + /// Public command surface for + /// with excludeCurrent: true — the Appearance page's Random + /// button when its Clothes sub-tab is showing + /// (gmCharGenMainUI::DoRandom case 3's + /// RandomizeClothing(state, 1) arm). + internal bool TryRandomizeClothing() + { + lock (_gate) + { + if (_disposed || !_active || _heritageId == 0 || _genderKey == 0) + return false; + RandomizeClothingLocked(excludeCurrent: true); + _revision++; + } + Publish(RuntimeCharacterCreationDeltaKind.StateChanged); + return true; + } + // ── Town / name / slot ───────────────────────────────────────────── /// Ports CharGenState::SetStartArea @ 0x005C4000 — bounds @@ -1290,16 +1682,19 @@ public sealed class RuntimeCharacterCreationState : IDisposable refusal = trimmed.Length == 0 ? new RuntimeCharacterCreationLocalRefusal( NoName: true, false, false, false) - : !confirmedUnspentCredits && _remainingAttributeCredits > 0 + : _heritageId == 0 || _genderKey == 0 ? new RuntimeCharacterCreationLocalRefusal( - false, AttributeCreditsUnspent: true, false, false) - : _verificationPending + false, false, false, false, HeritageOrGenderUnset: true) + : !confirmedUnspentCredits && _remainingAttributeCredits > 0 ? new RuntimeCharacterCreationLocalRefusal( - false, false, AlreadyPending: true, false) - : slotCount > 0 && rosterCount >= slotCount + false, AttributeCreditsUnspent: true, false, false) + : _verificationPending ? new RuntimeCharacterCreationLocalRefusal( - false, false, false, RosterFull: true) - : RuntimeCharacterCreationLocalRefusal.None; + false, false, AlreadyPending: true, false) + : slotCount > 0 && rosterCount >= slotCount + ? new RuntimeCharacterCreationLocalRefusal( + false, false, false, RosterFull: true) + : RuntimeCharacterCreationLocalRefusal.None; _lastLocalRefusal = refusal; accepted = !refusal.Any; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 347b092f..d159e27d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -122,7 +122,8 @@ public sealed class CharacterCreationLiveDatTests CharacterCreationUiController? controller = CharacterCreationUiController.CreateDetached( host, screen, ResolveTemplate, dialogs, bindings, - new CharacterCreationUiController.DialogStrings("Are you sure?")); + new CharacterCreationUiController.DialogStrings( + "Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long")); Assert.NotNull(controller); controller!.AttachAndTick(); controller.Dispose(); @@ -515,6 +516,62 @@ public sealed class CharacterCreationLiveDatTests return new RetailDialogFactory(host, CreateLayout); } + /// + /// Campaign CC slice CC5 — the Summary page's full authored widget + /// catalog: the name field (with NameInputFilter), the how-to + /// text, the viewport (Summary's OWN gmCG3DView), and the + /// listbox's THREE row templates (single-line, category-header, + /// key/value pair) confirmed against the installed EoR dat — see + /// 's own class doc for the + /// decomp citation (SetSummaryText @ 0x0047b1d0) each template + /// maps to. + /// + [InstalledDatFact] + public void SummaryPage_HasNameFieldListboxTemplatesAndViewport() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement summaryRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SummaryPageElementId)); + + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId)); + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.NameTextId)); + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.HowToTextId)); + Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ViewportId)); + + UiTemplateListBox list = Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId)); + Assert.Equal(3, list.Templates.Count); + + UiElement? ResolveRow(int index) => + LayoutImporter.Import( + dats, + list.Templates[index].TemplateLayoutId, + list.Templates[index].TemplateElementId, + _ => (0u, 0, 0), + null)?.Root; + + UiElement lineRow = Assert.IsAssignableFrom(ResolveRow(0)); + Assert.IsType(UiElement.FindDescendant(lineRow, 0x100002F9u)); + + UiElement headerRow = Assert.IsAssignableFrom(ResolveRow(1)); + Assert.IsType(UiElement.FindDescendant(headerRow, 0x100000FEu)); + + UiElement pairRow = Assert.IsAssignableFrom(ResolveRow(2)); + Assert.IsType(UiElement.FindDescendant(pairRow, 0x100002FCu)); + Assert.IsType(UiElement.FindDescendant(pairRow, 0x100002FDu)); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 567391bb..a12b8397 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -93,18 +94,32 @@ public sealed class CharacterCreationUiControllerTests CharacterCreationUiController.SummaryPageElementId).Visible); } + /// Campaign CC slice CC5: Finish (0x100003c8) is + /// ghosted everywhere EXCEPT Summary — retail's own + /// ListenToElementMessage case only sends when + /// m_eProgressState == ECG_SUMMARY; off Summary the button now + /// has a real OnClick handler (OnFinish, which itself + /// re-checks the current page) but stays disabled. [Fact] - public void Finish_StaysGhosted_NoOnClickHandler() + public void Finish_GhostedExceptOnSummary() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId); - Assert.Null(finish.OnClick); + Assert.NotNull(finish.OnClick); Assert.False(finish.Enabled); + + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + Assert.True(finish.Enabled); } + /// Campaign CC slice CC5: Random (0x100003cb) is now + /// enabled on Appearance and Summary too — CC5 ports + /// RandomizeAppearance/RandomizeClothing/RandomizeCharacter, retiring + /// both gaps AP-212 used to track. Only Skills' unported + /// RandomizeSkills keeps Random disabled. [Fact] - public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages() + public void Random_IsDisabledOnSkillsPageOnly() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); @@ -115,10 +130,10 @@ public sealed class CharacterCreationUiControllerTests Assert.False(random.Enabled); environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); - Assert.False(random.Enabled); + Assert.True(random.Enabled); environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); - Assert.False(random.Enabled); + Assert.True(random.Enabled); environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!(); Assert.True(random.Enabled); @@ -791,6 +806,304 @@ public sealed class CharacterCreationUiControllerTests environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!(); } + // ── CC5: RandomizeCharacter open-roll + gender flip ───────────────── + + /// Ports gmCharGenMainUI's ctor-time roll + + /// gmCGAppearancePage::InitializePage's gender-flip + /// (~0x004802da-0x00480303) — retiring AP-214's honest-blank + /// deviation. lands + /// deterministically on / + /// so the flip assertion + /// isn't flaky. + [Fact] + public void Open_RollsACharacterThenFlipsTheGenderToTheOpposite() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.RandomizedHeritageId = AluvianId; + environment.Runtime.RandomizedGenderKey = GenderKey; // Male = 1 + + environment.Controller.Open(); + + Assert.Equal(1, environment.Runtime.RandomizeCharacterCalls); + Assert.Equal(AluvianId, environment.Runtime.View.Snapshot.HeritageId); + // RandomizeCharacter rolled Male (1); InitializePage's own flip + // immediately inverts it to Female (2). + Assert.Equal(2u, environment.Runtime.LastSelectedGender); + Assert.Equal(2u, environment.Runtime.View.Snapshot.GenderKey); + } + + [Fact] + public void Open_RandomizeCharacterRejected_DoesNotAttemptTheGenderFlip() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.RandomizeCharacterAccepts = false; + + environment.Controller.Open(); + + Assert.Equal(0u, environment.Runtime.LastSelectedGender); + } + + // ── CC5: Finish (DoFinish @ 0x004E9170) ────────────────────────────── + + private static void GoToSummary(EnvironmentHarness environment) => + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + + [Fact] + public void Finish_EmptyName_ShowsNoNameWarningDialog_AndDoesNotSend() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("No name entered.", environment.LastDialogMessage()); + } + + [Fact] + public void Finish_UnspentCredits_ShowsCreditWarning_ConfirmResendsWithConfirmedFlag() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + Name = "Adventurer", + RemainingAttributeCredits = 6, + }; + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.False(environment.Runtime.LastConfirmedUnspentCredits); + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("You have unspent attribute credits.", environment.LastDialogMessage()); + + environment.ConfirmActiveDialog(confirmed: true); + + Assert.Equal(2, environment.Runtime.FinishCallCount); + Assert.True(environment.Runtime.LastConfirmedUnspentCredits); + } + + [Fact] + public void Finish_UnspentCredits_CancelDialog_DoesNotResend() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + Name = "Adventurer", + RemainingAttributeCredits = 6, + }; + GoToSummary(environment); + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + environment.ConfirmActiveDialog(confirmed: false); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + } + + [Fact] + public void Finish_Accepted_ShowsNoDialog() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + Name = "Adventurer", + RemainingAttributeCredits = 0, + }; + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.False(environment.Dialogs.IsOpen); + } + + [Fact] + public void Finish_OffSummaryPage_IsANoOp() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); // defaults to Heritage + + environment.Button(CharacterCreationUiController.FinishElementId).OnClick!(); + + Assert.Equal(0, environment.Runtime.FinishCallCount); + } + + // ── CC5: Random on Summary (MakeRandomizeWarningDialog @ 0x004e8a90) ─ + + [Fact] + public void RandomOnSummary_ShowsWarningDialog_ConfirmCallsRandomizeCharacter() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("This will randomize your character.", environment.LastDialogMessage()); + int callsBeforeConfirm = environment.Runtime.RandomizeCharacterCalls; + + environment.ConfirmActiveDialog(confirmed: true); + + Assert.Equal(callsBeforeConfirm + 1, environment.Runtime.RandomizeCharacterCalls); + } + + [Fact] + public void RandomOnSummary_CancelDialog_DoesNotRandomize() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + int callsBeforeClick = environment.Runtime.RandomizeCharacterCalls; + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + environment.ConfirmActiveDialog(confirmed: false); + + Assert.Equal(callsBeforeClick, environment.Runtime.RandomizeCharacterCalls); + } + + // ── CC5: Random on Appearance (DoRandom case 3) ────────────────────── + + [Fact] + public void RandomOnAppearance_FaceSubTab_CallsRandomizeAppearance() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.RandomizeAppearanceCalls); + Assert.Equal(0, environment.Runtime.RandomizeClothingCalls); + } + + [Fact] + public void RandomOnAppearance_ClothesSubTab_CallsRandomizeClothing() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!(); + + environment.Button(CharacterCreationUiController.RandomElementId).OnClick!(); + + Assert.Equal(1, environment.Runtime.RandomizeClothingCalls); + Assert.Equal(0, environment.Runtime.RandomizeAppearanceCalls); + } + + // ── CC5: Summary name field (ListenToElementMessage @ 0x0047bf40) ──── + + [Fact] + public void SummaryNameField_Submit_CommitsTheName() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + UiField field = environment.SummaryNameField(); + + field.SetText("Adventurer"); + field.Submit(); + + Assert.Equal("Adventurer", environment.Runtime.LastSetName); + } + + [Fact] + public void SummaryNameField_TooLong_ShowsDialogAndRevertsToLastCommitted() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + UiField field = environment.SummaryNameField(); + field.SetText("Adventurer"); + field.Submit(); + Assert.Equal("Adventurer", environment.Runtime.LastSetName); + + field.SetText(new string('a', 40)); + field.Submit(); + + // The over-limit text never reached SetName, and the too-long + // dialog fired with the field reverted to the last commit. + Assert.Equal("Adventurer", environment.Runtime.LastSetName); + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("That name is too long.", environment.LastDialogMessage()); + Assert.Equal("Adventurer", field.Text); + } + + [Fact] + public void SummaryNameField_NameInputFilter_RejectsDigitsAndSymbols() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + UiField field = environment.SummaryNameField(); + + Assert.NotNull(field.CharacterFilter); + Assert.True(field.CharacterFilter!('A')); + Assert.True(field.CharacterFilter!(' ')); + Assert.True(field.CharacterFilter!('\'')); + Assert.True(field.CharacterFilter!('-')); + Assert.False(field.CharacterFilter!('7')); + Assert.False(field.CharacterFilter!('$')); + } + + // ── CC5: 0xF643 rejection dialogs ───────────────────────────────────── + + [Fact] + public void CreationFailed_NameInUse_ShowsTheRetailErrorDialog_AndAcknowledgesOnClose() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use."; + environment.Controller.Open(); + + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + LastRejection = new RuntimeCharacterCreationRejection( + 3u, + CharGenVerificationResponse.Code.NameInUse, + "NameInUse", + "Adventurer"), + }; + BumpRevisionAndTick(environment); + + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("That name is in use.", environment.LastDialogMessage()); + + environment.DismissActiveMessageDialog(); + + Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls); + } + + [Fact] + public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_Character_Err_NameBanned"] = "That name is banned."; + environment.Controller.Open(); + + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with + { + LastRejection = new RuntimeCharacterCreationRejection( + 4u, CharGenVerificationResponse.Code.NameBanned, "NameBanned", "Adventurer"), + }; + BumpRevisionAndTick(environment); + Assert.Equal(1, environment.Dialogs.ActiveCount); + + // A second Tick with the SAME rejection instance (no revision bump, + // no new value) must not reopen the dialog — ReconcileDialogs runs + // every Tick, not just on revision change. + environment.Controller.Tick(); + Assert.Equal(1, environment.Dialogs.ActiveCount); + } + private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl { public int ZoomInCalls { get; private set; } @@ -850,7 +1163,11 @@ public sealed class CharacterCreationUiControllerTests Dialogs, Runtime.Bindings, new CharacterCreationUiController.DialogStrings( - "Are you sure you want to leave?"))); + "Are you sure you want to leave?", + "No name entered.", + "You have unspent attribute credits.", + "This will randomize your character.", + "That name is too long."))); Controller.AttachAndTick(); } @@ -874,6 +1191,9 @@ public sealed class CharacterCreationUiControllerTests public UiScrollbar ShadeScroll() => Assert.IsType(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId)); + public UiField SummaryNameField() => + Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.NameTextId)); + /// Confirms or cancels the MOST RECENTLY opened confirmation /// dialog, using 's real /// button ids off the layout the factory's createLayout @@ -889,10 +1209,39 @@ public sealed class CharacterCreationUiControllerTests button.OnClick!(); } + /// Same shape as , for a + /// plain informational () OK + /// dialog — /the 0xF643 + /// rejection dialogs use this shape, not confirm/cancel. + public void DismissActiveMessageDialog() + { + ImportedLayout dialog = _dialogLayouts[^1]; + UiButton button = Assert.IsType( + dialog.FindElement(RetailMessageDialogView.OkButtonId)); + button.OnClick!(); + } + + /// The MOST RECENTLY opened dialog's message text — + /// mirrors CharacterManagementUiControllerTests.Message's + /// own lookup shape (element id 0x3E, every + /// popup's + /// text child). + public string LastDialogMessage() => string.Join( + " ", + Assert.IsType(_dialogLayouts[^1].FindElement(0x3Eu)) + .LinesProvider() + .Select(static line => line.Text)); + private static UiElement? ResolveSkillRowTemplate( uint templateLayoutId, uint templateElementId) => - BuildSkillRowTemplate(templateElementId); + templateElementId switch + { + SummaryLineTemplateId => BuildSummaryLineTemplate(), + SummaryHeaderTemplateId => BuildSummaryHeaderTemplate(), + SummaryPairTemplateId => BuildSummaryPairTemplate(), + _ => BuildSkillRowTemplate(templateElementId), + }; public void Dispose() { @@ -919,11 +1268,16 @@ public sealed class CharacterCreationUiControllerTests skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized), skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained), SelectStartArea, - _ => Result(RuntimeCommandStatus.Accepted), + Finish, () => RequestExitCalls++, SetAppearanceIndex: SetAppearanceIndex, SetShade: SetShade, - ResolveText: _ => null, + ResolveText: key => ResolvedStrings.TryGetValue(key, out string? value) ? value : null, + SetName: SetName, + AcknowledgeRejection: AcknowledgeRejection, + RandomizeCharacter: RandomizeCharacter, + RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); }, + RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); }, OpenOnStart: false); } @@ -942,6 +1296,36 @@ public sealed class CharacterCreationUiControllerTests public int AppearanceIndexCallCount { get; private set; } public ChargenShadeSlot? LastShadeSlot { get; private set; } public double LastShadeValue { get; private set; } + public string? LastSetName { get; private set; } + public int FinishCallCount { get; private set; } + public bool LastConfirmedUnspentCredits { get; private set; } + public int AcknowledgeRejectionCalls { get; private set; } + public int RandomizeCharacterCalls { get; private set; } + public int RandomizeAppearanceCalls { get; private set; } + public int RandomizeClothingCalls { get; private set; } + + /// + /// The exact HeritageId/GenderKey a fake RandomizeCharacter roll + /// lands on — deterministic (not random) so tests can assert the + /// flip-to-opposite-gender behavior precisely. Defaults to 0/0 (a + /// no-op "roll") so the 37 PRE-EXISTING Open() call sites in + /// this file — written against the honest-blank-open contract + /// AP-214 tracked before this slice — keep observing a blank + /// heritage/gender after Open() without every one of them + /// having to opt out individually; only the tests THIS slice adds + /// that specifically exercise the roll set these explicitly. + /// + public uint RandomizedHeritageId { get; set; } + public uint RandomizedGenderKey { get; set; } + + /// Lets a test simulate the Runtime-inactive/rejected case + /// (e.g. a session already gone) without needing a real + /// RuntimeCharacterCreationState. + public bool RandomizeCharacterAccepts { get; set; } = true; + + /// Populated by tests exercising the ID_Character_Err_* + /// rejection-dialog path — ResolveText above reads from it. + public Dictionary ResolvedStrings { get; } = []; public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); @@ -1007,6 +1391,65 @@ public sealed class CharacterCreationUiControllerTests return Result(RuntimeCommandStatus.Accepted); } + private RuntimeCommandResult SetName(string name) + { + LastSetName = name; + View.Snapshot = View.Snapshot with { Name = name }; + return Result(RuntimeCommandStatus.Accepted); + } + + /// Mirrors RuntimeCharacterCreationState.TryBeginFinish's + /// refusal-priority chain closely enough to drive + /// CharacterCreationUiController.TryFinish's own dialog + /// dispatch under test — NoName, then HeritageOrGenderUnset, then + /// (unless confirmed) AttributeCreditsUnspent, else Accepted. + private RuntimeCommandResult Finish(bool confirmUnspentCredits) + { + FinishCallCount++; + LastConfirmedUnspentCredits = confirmUnspentCredits; + RuntimeCharacterCreationSnapshot snapshot = View.Snapshot; + string trimmed = snapshot.Name.Trim(); + + RuntimeCharacterCreationLocalRefusal refusal = trimmed.Length == 0 + ? new RuntimeCharacterCreationLocalRefusal(NoName: true, false, false, false) + : snapshot.HeritageId == 0u || snapshot.GenderKey == 0u + ? new RuntimeCharacterCreationLocalRefusal( + false, false, false, false, HeritageOrGenderUnset: true) + : !confirmUnspentCredits && snapshot.RemainingAttributeCredits > 0 + ? new RuntimeCharacterCreationLocalRefusal( + false, AttributeCreditsUnspent: true, false, false) + : RuntimeCharacterCreationLocalRefusal.None; + + View.Snapshot = snapshot with { Name = trimmed, LastLocalRefusal = refusal }; + return Result(refusal.Any ? RuntimeCommandStatus.Rejected : RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult AcknowledgeRejection() + { + AcknowledgeRejectionCalls++; + View.Snapshot = View.Snapshot with { LastRejection = null }; + return Result(RuntimeCommandStatus.Accepted); + } + + /// Deterministic fake for + /// RuntimeCharacterCreationState.TryRandomizeCharacter — real + /// randomness would make the gender-flip assertion in + /// Open_RollsARandomCharacterThenFlipsAppearancePageGender + /// flaky, so this always lands on / + /// instead. + private RuntimeCommandResult RandomizeCharacter() + { + RandomizeCharacterCalls++; + if (!RandomizeCharacterAccepts) + return Result(RuntimeCommandStatus.Rejected); + View.Snapshot = View.Snapshot with + { + HeritageId = RandomizedHeritageId, + GenderKey = RandomizedGenderKey, + }; + return Result(RuntimeCommandStatus.Accepted); + } + private static RuntimeCharacterCreationAppearance WithAppearanceIndex( RuntimeCharacterCreationAppearance a, ChargenAppearanceSlot slot, @@ -1227,7 +1670,7 @@ public sealed class CharacterCreationUiControllerTests root.Children.Add(BuildSkillsPage()); root.Children.Add(BuildAppearancePage()); root.Children.Add(BuildTownPage()); - root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); + root.Children.Add(BuildSummaryPage()); root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId)); @@ -1419,6 +1862,77 @@ public sealed class CharacterCreationUiControllerTests _ => (0u, 0, 0), null).Root; + // ── Summary page fixture (CC5) ─────────────────────────────────────── + // Template element ids match the LIVE-DAT-probe-confirmed retail ones + // (CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport) + // for readability, though this hand-built fixture doesn't require it. + + private const uint SummaryLineTemplateId = 0x100002F8u; + private const uint SummaryHeaderTemplateId = 0x100002FAu; + private const uint SummaryPairTemplateId = 0x100002FBu; + + private static ElementInfo BuildSummaryPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.SummaryPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + + var list = new ElementInfo + { + Id = CharacterCreationSummaryPage.ListBoxId, + Type = 5u, + X = 20f, + Y = 40f, + Width = 400f, + Height = 300f, + }; + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryLineTemplateId)); + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryHeaderTemplateId)); + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, SummaryPairTemplateId)); + page.Children.Add(list); + + page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId)); + page.Children.Add(EditableFieldInfo(CharacterCreationSummaryPage.NameTextId)); + page.Children.Add(TextInfo(CharacterCreationSummaryPage.HowToTextId)); + + var viewport = new ElementInfo + { + Id = CharacterCreationSummaryPage.ViewportId, + Type = 0xDu, + Width = 300f, + Height = 300f, + }; + page.Children.Add(viewport); + + return page; + } + + private static UiElement BuildSummaryLineTemplate() + { + var root = new ElementInfo { Id = 0x90001u, Type = 3u, Width = 380f, Height = 16f }; + root.Children.Add(TextInfo(0x100002F9u)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; + } + + private static UiElement BuildSummaryHeaderTemplate() + { + var root = new ElementInfo { Id = 0x90002u, Type = 3u, Width = 380f, Height = 18f }; + root.Children.Add(TextInfo(0x100000FEu)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; + } + + private static UiElement BuildSummaryPairTemplate() + { + var root = new ElementInfo { Id = 0x90003u, Type = 3u, Width = 380f, Height = 16f }; + root.Children.Add(TextInfo(0x100002FCu)); + root.Children.Add(TextInfo(0x100002FDu)); + return LayoutImporter.Build(root, _ => (0u, 0, 0), null).Root; + } + private static ElementInfo ContainerInfo(uint id) => new() { Id = id, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs index 4e1480fc..452d5ff0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs @@ -290,7 +290,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests _dialogs, Runtime.Bindings, new CharacterCreationUiController.DialogStrings( - "Are you sure you want to leave?"))); + "Are you sure you want to leave?", + "No name", "Unspent credits", "Randomize?", "Name too long"))); Controller.AttachAndTick(); } diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs index cb06bb7a..2103550c 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateFixture.cs @@ -21,6 +21,15 @@ internal static class RuntimeCharacterCreationStateFixture public const uint ImpoverishedId = 90u; public const uint MaleGenderKey = 1u; + /// Campaign CC slice CC5: RandomizeCharacter's gender + /// roll (RollDice(1,2)) needs BOTH gender keys resolvable on the + /// four human heritages, or half of all random seeds would land on a + /// gender can't + /// resolve and silently leave appearance untouched (a real, harmless + /// ConstrainAllByGender-style fallback — but not what these tests + /// are pinning). + public const uint FemaleGenderKey = 2u; + /// str=10 end=10 coord=10 quick=10 focus=10 self=10 — the /// budget-66 heritage leaves 6 credits unspent after this template, /// matching retail's "Custom sits at the floor" finding. @@ -99,6 +108,16 @@ internal static class RuntimeCharacterCreationStateFixture Footwear: [new ChargenGearOption("Boots", 4u, 303u)], ClothingColors: [400u, 401u, 402u]); + // Same shape as `gender`, just the other GenderKey — every list is + // deliberately populated so a full RandomizeCharacter roll never + // finds an empty option list to skip. + var femaleGender = gender with { GenderKey = (int)FemaleGenderKey, Name = "Female" }; + var bothGenders = new Dictionary + { + [(int)MaleGenderKey] = gender, + [(int)FemaleGenderKey] = femaleGender, + }; + var aluvianTemplates = new List { new( @@ -129,7 +148,7 @@ internal static class RuntimeCharacterCreationStateFixture SecondaryStartAreaIndices: [], SkillCostsBySkillId: skillCosts, Templates: aluvianTemplates, - GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + GendersByKey: bothGenders); var olthoiTemplates = new List { @@ -163,6 +182,28 @@ internal static class RuntimeCharacterCreationStateFixture Templates: olthoiTemplates, GendersByKey: new Dictionary { [(int)MaleGenderKey] = gender }); + // Campaign CC slice CC5: CharGenState::RandomizeCharacter @ + // 0x005c6d80 rolls a heritage id uniformly in [1, hasToD?4:3] — the + // four HUMAN heritage groups (ChargenHeritageGroup.Aluvian.. + // Viamontian). RandomizeCharacterLocked's tests need every one of + // those four ids resolvable, not just Aluvian, so the roll can never + // silently land on a missing heritage. Ids 2-4 mirror Aluvian's own + // shape (same gender/template data) — the roll target, not the + // template/skill-budget math, is what those tests exercise. + ChargenHeritageOptions MakeHumanHeritage(uint id, string name) => new( + id, + name, + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 66u, + SkillCredits: 50u, + PrimaryStartAreaIndices: [0, 1], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: skillCosts, + Templates: aluvianTemplates, + GendersByKey: bothGenders); + // A deliberately impoverished heritage — just enough skill credits // to train SkillTrainSpecialize but never specialize it — so a // TrySpecializeSkill affordability refusal is directly testable @@ -198,6 +239,12 @@ internal static class RuntimeCharacterCreationStateFixture new Dictionary { [AluvianId] = aluvian, + [(uint)ChargenHeritageGroup.Gharundim] = MakeHumanHeritage( + (uint)ChargenHeritageGroup.Gharundim, "Gharu'ndim"), + [(uint)ChargenHeritageGroup.Sho] = MakeHumanHeritage( + (uint)ChargenHeritageGroup.Sho, "Sho"), + [(uint)ChargenHeritageGroup.Viamontian] = MakeHumanHeritage( + (uint)ChargenHeritageGroup.Viamontian, "Viamontian"), [OlthoiId] = olthoi, [ImpoverishedId] = impoverished, }, diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs index 175364ea..d73d38b2 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -555,6 +555,42 @@ public sealed class RuntimeCharacterCreationStateTests Assert.True(refusal.AlreadyPending); } + /// + /// F12 amendment (CC6b-MOUNT review fix round, filed as register + /// AP-223): with AD-101 retired, a caller could otherwise reach Finish + /// with heritage/gender still unset. Retail's own DoFinish never + /// checks this because RandomizeCharacter guarantees it can't + /// happen — this is acdream's own defensive backstop for any caller that + /// bypasses the App layer's screen-open roll. + /// + [Fact] + public void TryBeginFinish_HeritageUnset_IsRefused() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySetName("Adventurer"); + + bool accepted = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.HeritageOrGenderUnset); + Assert.False(refusal.NoName); + } + + [Fact] + public void TryBeginFinish_GenderUnset_IsRefused() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySetName("Adventurer"); + + bool accepted = state.TryBeginFinish( + 0, 11, out _, out _, out RuntimeCharacterCreationLocalRefusal refusal); + + Assert.False(accepted); + Assert.True(refusal.HeritageOrGenderUnset); + } + [Fact] public void TryBeginFinish_RosterAtSlotCap_IsRefused() { @@ -700,4 +736,162 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Null(state.Snapshot.LastRejection); } + + // ── Randomize (Campaign CC slice CC5, RandomizeCharacter port) ─────── + + /// + /// Ports CharGenState::RandomizeCharacter @ 0x005c6d80: rolls a + /// heritage id in [1,4] (the four HUMAN groups, never the other nine), + /// a gender in [1,2], and freezes both non-Unset. A 200-iteration sweep + /// with a fresh seeded RNG per iteration proves the heritage roll never + /// escapes the 1-4 human-only range even though the fixture ALSO + /// carries a non-human Olthoi heritage (id 12) and an out-of-range + /// "Impoverished" heritage (id 90) that a broken roll could otherwise + /// land on. + /// + [Fact] + public void TryRandomizeCharacter_RollsOnlyTheFourHumanHeritagesAndAGender() + { + for (int seed = 0; seed < 200; seed++) + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(seed)); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TryRandomizeCharacter()); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + Assert.InRange(snapshot.HeritageId, 1u, 4u); + Assert.True(snapshot.GenderKey is 1u or 2u); + } + } + + [Fact] + public void TryRandomizeCharacter_RollsAppearanceClothingTemplateAndStartArea() + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(7)); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TryRandomizeCharacter()); + + RuntimeCharacterCreationSnapshot snapshot = state.Snapshot; + // Every list in the fixture's shared gender record is non-empty, so + // a full randomize must leave nothing Unset. + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.EyesStrip); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.HairColor); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.ShirtStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.TrousersStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, snapshot.Appearance.FootwearStyle); + Assert.NotEqual(RuntimeCharacterCreationSnapshot.TemplateUnset, snapshot.Template); + // Template is one of the PRESET rows (never index 0/Custom) — + // RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape. + Assert.NotEqual(0u, snapshot.Template); + Assert.True(snapshot.StartArea is 0 or 1); + } + + /// + /// RandomizeTemplateLocked's own Olthoi/OlthoiAcid branch + /// (mirroring CharGenState::RandomizeTemplate's force-to-template-0 + /// arm) is UNREACHABLE through + /// specifically — that caller's own heritage roll is always one of the + /// four HUMAN ids (never Olthoi), matching retail's identical + /// architecture (RandomizeCharacter's heritage roll and + /// RandomizeTemplate's Olthoi branch are independent call paths; + /// retail never composes them either, since a random CHARACTER is never + /// Olthoi). The branch is not otherwise exposed as a standalone command + /// this slice (out of CC5's named scope), so its OBSERVABLE behavior — + /// selecting Olthoi always forces template 0 — is already covered by + /// TrySelectHeritage_Olthoi.../ApplyTemplate coverage + /// elsewhere in this file; this test only pins that a full + /// TryRandomizeCharacter roll never lands on Olthoi in the first + /// place, over enough iterations to catch a boundary-off-by-one. + /// + [Fact] + public void TryRandomizeCharacter_NeverRollsANonHumanHeritage() + { + for (int seed = 0; seed < 200; seed++) + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(seed)); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TryRandomizeCharacter()); + + Assert.NotEqual(RuntimeCharacterCreationStateFixture.OlthoiId, state.Snapshot.HeritageId); + Assert.NotEqual(RuntimeCharacterCreationStateFixture.ImpoverishedId, state.Snapshot.HeritageId); + } + } + + [Fact] + public void TryRandomizeCharacter_Inactive_IsRejected() + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build()); + Assert.False(state.TryRandomizeCharacter()); + } + + [Fact] + public void TryRandomizeAppearance_RequiresHeritageAndGender() + { + RuntimeCharacterCreationState state = CreateActive(); + Assert.False(state.TryRandomizeAppearance()); + + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + Assert.False(state.TryRandomizeAppearance()); + + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + Assert.True(state.TryRandomizeAppearance()); + Assert.NotEqual( + RuntimeCharacterCreationAppearance.Unset, + state.Snapshot.Appearance.HairStyle); + } + + [Fact] + public void TryRandomizeClothing_RollsAllFourGearSlots() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + Assert.True(state.TryRandomizeClothing()); + + RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance; + // Headgear excludes-current with the +1 Unset-ring reindex — the + // fixture's single headgear style means the roll can only land on + // style 0 or Unset; either is a valid outcome of the ring, so this + // just confirms the call actually touched the field (shirt/trousers/ + // footwear below have no Unset ring and must land on their one + // style). + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.ShirtStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.TrousersStyle); + Assert.NotEqual(RuntimeCharacterCreationAppearance.Unset, a.FootwearStyle); + } + + /// + /// RandInt(int,int) @ 0x00684420's own decompiled shape: re-roll + /// until the result differs from the excluded value, UNLESS there is + /// only one possible outcome ( <= 1), which + /// returns 0 immediately without ever comparing against exclude (the + /// guard that keeps the loop from spinning forever). This drives + /// + /// enough times to statistically prove the shirt slot (the fixture's + /// single-style list) never gets stuck — count<=1 must short-circuit. + /// + [Fact] + public void TryRandomizeClothing_SingleOptionList_NeverHangs() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + for (int i = 0; i < 50; i++) + Assert.True(state.TryRandomizeClothing()); + + Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle); + } } From a975efd1d5c490b538ed9aae996214262b46d6e6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 00:01:16 +0200 Subject: [PATCH 100/138] =?UTF-8?q?docs:=20CC5=20ledger=20=E2=80=94=20reco?= =?UTF-8?q?rd=20the=20commit=20SHA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 34e3a534: the ledger row was written before the commit existed, so it referenced "this session's commit(s)" as a placeholder. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-15-character-creation-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 98b36370..ab18b573 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -252,7 +252,7 @@ the user gate. | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | -| CC5 | CODE-COMPLETE 2026-08-15 | (this session's commit(s) — see git log for `feat(chargen): Campaign CC slice CC5`) | OWED (dual-lens review pending) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied` (Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` already treats them as a silent reset with no `RuntimeCharacterCreationRejection` produced at all); dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | +| CC5 | CODE-COMPLETE 2026-08-15 | `34e3a534` | OWED (dual-lens review pending) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied` (Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` already treats them as a silent reset with no `RuntimeCharacterCreationRejection` produced at all); dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | From 0c8e1e7df1b20c75bac970420c298d1b8220cd85 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 01:13:52 +0200 Subject: [PATCH 101/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC5=20r?= =?UTF-8?q?eview=20fix=20round=20=E2=80=94=20F1-F14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of 34e3a534+a975efd1 returned architectural PASS-with-items / retail-fidelity FAIL. Every finding fixed: - F1 (BLOCKER): deleted CharacterCreationSummaryPage's dead _suppressNextFieldEvent latch. UiField.SetText never raises OnFocusLost/OnSubmit, so the latch never had anything genuine to suppress — it stayed armed until the player's own next real commit and silently ate their typed name. - F2: byte-re-derived gmCharGenMainUI::RecvNotice_ CharGenVerificationResponse @0x004e9030's jump table — Pending is an explicit switch case landing on the SAME NameDBDown label as Corrupt/DatabaseDown, and Undef/out-of-range falls through the function's own unsigned-underflow default arm to that identical label. Retail's dispatch has NO silent branch. ApplyCreationResponse now produces a real rejection for Pending/Undef instead of a silent reset; ReconcileDialogs maps them to NameDBDown. Corrects the wrong "retail swallows Pending" claim everywhere it was repeated (plan doc, Core.Net doc comment, Runtime doc comments). - F3: skill rows now use the key/value template with CharGenState::GetSkillScore @0x005C4B50 as the value (ported via the new RetailSkillFormula.CalculateChargenScore / ChargenSkillScoreResolver, wired through a new GetSkillScore binding), not template 0/name-only; bucket headers are unconditional. Writing this fix's own regression test surfaced a second, more severe bug: CharacterCreationSummaryPage never wired _list.TemplateResolver at all, so RebuildListbox has been a silent no-op since CC5 shipped — fixed by threading templateResolver through the page's constructor, matching every sibling UiTemplateListBox owner. - F4: added the missing _errorMessageDialogContext one-outstanding guard to the 0xF643 rejection dialog, matching MakeErrorMessageDialog's own guard @0x004e8cc4 and the other four sibling dialogs' shape (registered in CloseAllDialogs, suppress- callback checked). - F5: the Summary preview camera now seeds/re-derives retail's zoomed-OUT eye (byte-decoded (0,-2.5,0.95) at gmCGSummaryPage:: InitializePage ~0x0047bd14-0x0047bd44) instead of Appearance's zoomed-in default, via a new ChargenPreviewController useZoomedOutEye flag. - F6: retired AP-225 outright — re-derived the ListenToElementMessage length gate is NUL-inclusive, so MaxNameLength=32 was always byte-correct, not merely internally consistent. - F7: amended AP-221 to cover the Summary preview's duplicate one-shot-composition binding gap (CC5 duplicated the pattern instead of closing it). - F8: byte-decoded GetRandomReal @0x00563940's fmul operand at 0x007cd650 — an 8-byte double, not a 4-byte float — is EXACTLY 1.0/32767.0, not 1/32768. Added RollShadeLocked (_random.Next(32768) * (1.0/32767.0)) and switched all six shade rolls onto it. - F9: evaluated porting retail's exact empty-name-commit no-op (NUL-inclusive length==1 skips SetName entirely) and rejected it — it would fight the F1 field-sync model by spontaneously reverting an emptied field on the next unrelated revision bump. Kept the clear, documented the tradeoff, filed AP-227. - F11: filed AP-226 documenting retail's static pcProfessions/pcGender/ pcHeritage/pcTown label tables versus acdream's DAT-sourced labels, including the non-human-heritage-renders-bare-"Heritage:" retail quirk. - F12: added exclude-current determinism (count-2 lists), Random- clears-name, repeat-identical-rejection-reshows, and RebuildListbox content tests (the last one found F3's TemplateResolver bug). - F13: threaded an optional Random through GameRuntimeDependencies -> LiveSessionController -> RuntimeCharacterCreationState, matching the existing TimeProvider injection shape, closing the Slice-K determinism hazard on a bot-reachable Randomize* command family. - F14: RandomizeCharacterLocked now assigns _heritageId unconditionally before the TryGetHeritage gate, matching retail's SetHeritageGroup @0x005C67A0 (mHeritageGroup written before the DAT lookup). Gates: Runtime 1726/0 (was 1722/0), App 5242/3 skips (was 5240/3), Headless 166/0, Core.Net 993/994 (the one failure, NakEmissionTests LossSoak, is a known pre-existing flake — passes standalone), full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 9 +- .../2026-08-15-character-creation-campaign.md | 28 +++- .../InteractionRetainedUiComposition.cs | 15 ++ .../LivePresentationComposition.cs | 26 ++-- src/AcDream.App/Net/RetailSkillFormula.cs | 91 ++++++++++++ .../Rendering/ChargenPreviewController.cs | 34 ++++- .../UI/Layout/CharacterCreationSummaryPage.cs | 120 +++++++++++---- .../Layout/CharacterCreationUiController.cs | 79 +++++++--- .../Messages/CharGenVerificationResponse.cs | 29 ++-- src/AcDream.Runtime/GameRuntime.cs | 19 ++- .../Session/LiveSessionController.cs | 10 +- .../Session/RuntimeCharacterCreationState.cs | 136 ++++++++++++----- .../CharacterCreationUiControllerTests.cs | 125 ++++++++++++++++ .../RuntimeCharacterCreationStateTests.cs | 140 +++++++++++++++--- 14 files changed, 720 insertions(+), 141 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1e16834c..b20384ff 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 160 active rows (AP-223/AP-224/AP-225 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal, the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing, and the Summary name field's 32-vs-33 length-threshold reconciliation; AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 161 active rows (AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -207,8 +207,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-225 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary page's name field).** Retail's chargen name buffer is `char name[33]` (32 usable chars + null terminator — `CharGenState`'s own struct field, `acclient.h`). The UI-side pre-commit length check at `gmCGSummaryPage::ListenToElementMessage @ 0x0047bf40` (`~0x0047bfd1`) compares the raw input against the literal `0x21` (33), rejecting anything longer — but the exact base of that decompiled comparison (visible character count vs. an internal length-prefix accounting the decompiler didn't resolve cleanly) is not fully certain from the pseudo-C. `CharacterCreationSummaryPage`'s own `MaxNameLength` uses 32, matching the ALREADY-ESTABLISHED `RuntimeCharacterCreationState.TrySetName` storage cap (CC3), rather than trusting the ambiguous 1-off literal over that reviewed contract — a name of exactly 33 characters is the only value where the two thresholds could disagree. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`MaxNameLength`) | Internal consistency between the UI-level reject-and-revert threshold and the Runtime storage cap is more valuable than an unverified 1-character decomp literal — a real divergence here would show up as "the field accepts 33 characters but the create sends 32," which this alignment prevents by construction. | If retail's actual usable cap is genuinely 33 (not 32), a 33-character name that should be accepted gets rejected with the too-long dialog instead — a narrow, one-character-wide UX mismatch, never a data-corruption risk (the wire format truncates to whatever `TrySetName` already stores either way). | `gmCGSummaryPage::ListenToElementMessage @ 0x0047bf40`; `CharGenState.name[33]` (`acclient.h`); `RuntimeCharacterCreationState.TrySetName` (CC3) | -| AP-224 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary listbox content).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` walks FOUR skill buckets (Specialized, Trained, UseableUntrained, UnuseableUntrained) and lists every skill name in each, via a nested loop over `skillRecordList`. `CharacterCreationSummaryPage.AddSkillBucket` lists Specialized and Trained only, skipping the two Untrained buckets — mirroring AP-213's own already-accepted Skills-page simplification precedent (same class of cut: presentation grouping, not correctness). Health/Stamina/Mana values reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas (Health=Endurance/2, Stamina=Endurance, Mana=Self) rather than this page's OWN `SetSummaryText` call site, whose two `GetAttribute` calls for Health/Stamina both show a literal attribute index of `2` in the decompiled pseudo-C — a decompiler-ambiguous pair the cleaner Profession-page citation sidesteps rather than reproduces uncritically. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`RebuildListbox`, `AddSkillBucket`) | The two Untrained buckets would list the ~40+ skills the player did NOT touch — volume without decision-relevant information for a pre-Finish review screen; every skill's actual cost/level data remains identical and inspectable on the Skills page itself. The Health/Stamina/Mana citation choice favors a decomp site with an unambiguous formula over one with a decompiler artifact. | A player scanning Summary for "what am I NOT trained in" has to go back to the Skills page instead of seeing it listed here — a discoverability gap, not a correctness gap; the row TEMPLATE mechanism itself (three retail row types: single-line, header, key/value pair) is ported exactly, live-DAT-probe-confirmed, not simplified. | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0`; `CharacterCreationProfessionPage.Refresh`'s own `UpdateAttributeValues @ 0x00482450` citation | +| AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | +| AP-226 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 (the Summary listbox's Profession/Gender/Heritage/Starting Town label sources).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` sources these four labels from four STATIC wide-string tables baked into the binary's data section: `pcProfessions[0x7] @ 0x008191a8` ("Custom", "Bow Hunter", "Swashbuckler", "Life Caster", "War Mage", "Wayfarer", "Soldier"), `pcGender[0x3] @ 0x008191c4` ("?", "Male", "Female"), `pcHeritage[0x5] @ 0x008191d0` ("?", "Aluvian", "Gharu'ndim", "Sho", "Viamontian"), `pcTown[0x4] @ 0x008191e4` ("Holtburg", "Shoushi", "Yaraq", "Sanamar") — each indexed directly by the character's `template_`/`mGender`/`mHeritageGroup`/`startArea` field, each guarded by an upper-bound-only range check (`template_ <= 6`, `mGender <= 2`, `mHeritageGroup <= 4`, `startArea <= 3`) with NO append at all when the index is out of range. Concretely: **`pcHeritage`'s guard is `mHeritageGroup <= 4` — heritage ids 5 and above (every NON-HUMAN heritage: Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, Olthoi, OlthoiAcid) are never appended, so retail's own Summary page renders a BARE `"Heritage: "` with no name at all for a non-human character** — a genuine retail quirk, not a decompiler artifact (confirmed by the same guard shape on all four tables). `CharacterCreationSummaryPage`'s port instead sources every label from the already-loaded `ChargenOptions` DAT model (`heritage.Templates[i].Name`, `gender.Name`, `heritage.Name`, `options.StarterAreas[i].Name`) and prints the literal `"None"` when the index is unresolved, for EVERY heritage including non-human ones — never a bare label. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`ProfessionName`, `GenderName`, `RebuildListbox`'s `"Heritage: " + heritage.Name`, `StarterAreaName`) | The DAT-sourced names are the SAME strings a player already sees on every earlier chargen page (Heritage/Profession/Town pages all source from the identical `ChargenOptions` model) — reusing them keeps the Summary page internally consistent with the rest of the screen rather than introducing a second, static, English-only label source that could drift from the DAT (localization, a modded heritage table) or blank out for heritages retail's own hardcoded table never anticipated. | A pixel-level side-by-side against retail would show a non-human character's Summary "Heritage:" row completely empty of a name in retail (an accepted retail bug/limitation) versus acdream always showing the real heritage name — a cosmetic improvement, never a correctness or wire-format difference; a non-English/modded DAT install could theoretically show acdream a label retail's hardcoded English table never had, which is again strictly more informative, not less. | `pcProfessions[0x7] @0x008191a8`; `pcGender[0x3] @0x008191c4`; `pcHeritage[0x5] @0x008191d0`; `pcTown[0x4] @0x008191e4`; `gmCGSummaryPage::SetSummaryText @0x0047b1d0` (the four guard+append sites) | +| AP-224 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary listbox content).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` walks FOUR skill buckets (Specialized, Trained, UseableUntrained, UnuseableUntrained) and lists every skill name in each, via a nested loop over `skillRecordList`. `CharacterCreationSummaryPage.AddSkillBucket` lists Specialized and Trained only, skipping the two Untrained buckets — mirroring AP-213's own already-accepted Skills-page simplification precedent (same class of cut: presentation grouping, not correctness). Health/Stamina/Mana values reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas (Health=Endurance/2, Stamina=Endurance, Mana=Self) rather than this page's OWN `SetSummaryText` call site, whose two `GetAttribute` calls for Health/Stamina both show a literal attribute index of `2` in the decompiled pseudo-C — a decompiler-ambiguous pair the cleaner Profession-page citation sidesteps rather than reproduces uncritically. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`RebuildListbox`, `AddSkillBucket`) | The two Untrained buckets would list the ~40+ skills the player did NOT touch — volume without decision-relevant information for a pre-Finish review screen; every skill's actual cost/level data remains identical and inspectable on the Skills page itself. The Health/Stamina/Mana citation choice favors a decomp site with an unambiguous formula over one with a decompiler artifact. | A player scanning Summary for "what am I NOT trained in" has to go back to the Skills page instead of seeing it listed here — a discoverability gap, not a correctness gap; the row TEMPLATE mechanism itself (three retail row types: single-line, header, key/value pair) is ported exactly, live-DAT-probe-confirmed, not simplified. **Correction, CC5 review-fix round F3 (2026-08-16): this last claim was FALSE as originally shipped — the skill rows this row's own `AddSkillBucket` builds used template 0 (single line, name only) instead of template 2 (key/value pair, `CharGenState::GetSkillScore @ 0x005C4B50` as the value) and its bucket headers were added lazily (only when the bucket had a match) instead of retail's own unconditional add. Both are fixed this round (`RetailSkillFormula.CalculateChargenScore`, wired via the new `CharacterCreationRuntimeBindings.GetSkillScore` binding) — the "ported exactly, not simplified" claim is true again, but it was not verified against the ACTUAL row template/value at CC5 ship time, only against the listbox's INDEX/TYPE shape. A SEPARATE, more severe bug surfaced writing this round's own regression test (F12(d)): `CharacterCreationSummaryPage`'s constructor never assigned `_list.TemplateResolver` at all (every sibling `UiTemplateListBox` owner — `CharacterCreationSkillsPage`, `CharacterManagementUiController`, every Options-panel controller — does this in its own constructor; this page never did), so `ResolveTemplateRow`'s own null-resolver guard made EVERY `RebuildListbox` call a silent no-op — the Summary listbox rendered NO rows at all (not just wrong-template skill rows) from CC5's ship date until this fix. Also fixed this round (`CharacterCreationSummaryPage`'s new `templateResolver` constructor parameter).** | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0`; `CharacterCreationProfessionPage.Refresh`'s own `UpdateAttributeValues @ 0x00482450` citation; `CharGenState::GetSkillScore @ 0x005C4B50`; `SkillFormula::Calculate @ 0x00591960` | | AP-223 | **Filed 2026-08-15 at Campaign CC slice CC5 (the F12 amendment's own explicit ask — see AP-214's now-retired "Latent Finish-path interaction" note).** `RuntimeCharacterCreationState.TryBeginFinish` gains a NEW local refusal, `HeritageOrGenderUnset`, checked right after the empty-name check. Retail's own `gmCharGenMainUI::DoFinish @ 0x004E9170` has NO such check in the decompiled code — but it doesn't need one: `RandomizeCharacter` at ctor time (now ported, see AD-101/AP-212/AP-214's history) guarantees heritage+gender are ALWAYS real by the time any page — including Summary/Finish — exists. This refusal is acdream's OWN defensive backstop for a caller that reaches `Finish` without that screen-open roll ever having run (a headless bot driving `RuntimeCharacterCreationState` directly, or a future caller that bypasses `CharacterCreationUiController.Open`). Under the ordinary UI it is normally unreachable (the roll always fires first). | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`, `TryBeginFinish`) | Retail's own guarantee is architectural (a roll that always runs before any page exists), not a runtime check — acdream's UI reproduces the roll (`CharacterCreationUiController.Open` → `RollOpeningCharacter`) but a direct Runtime caller could still skip it, so a local refusal is the honest choice over silently sending a heritage-0/gender-0 wire request ACE would likely reject anyway for unrelated reasons. | A caller that bypasses the normal screen-open path and calls `Finish` before ever selecting heritage/gender gets a local refusal instead of a wire round-trip to discover the same failure — no server-visible consequence either way. | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`, the ctor-time roll); `CharGenState::RandomizeCharacter @0x005c6d80`; `gmCharGenMainUI::DoFinish @ 0x004E9170` (no heritage/gender check present) | | AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | @@ -398,7 +399,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | -| AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`) and creature-appraisal in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of the CC6b-MOUNT fix round's blast radius; the re-reviewer accepted the narrower diagnostic-only fix (R1) as sufficient for this round with this row as the tracked follow-up. | On the specific unlucky frame where the coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose 3D preview zoom/rotate controls are dead for the ENTIRE session with no visible error beyond the (narrowed) console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of this binding (CC5 or a follow-up slice) should close. | `src/AcDream.App/Composition/LivePresentationComposition.cs:996-1104` (chargen preview block's own F8 disposition comment); `RetailUiRuntime.ChargenPreviewViewportWidget`'s doc comment (retry-vs-one-shot contrast) | +| AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | | AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index ab18b573..2b5ff0e6 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -90,12 +90,26 @@ roster (`Handle_CharGenVerificationResponse@0x0055E8B0` case 1 → `CharacterSet::AddIdentity`) and `gmCharGenMainUI::Update@236161` then watches the set and calls `CPlayerSystem::LogOnCharacter` DIRECTLY when the new name appears (logs straight in; only falls back to char management if it -never appears). Error dialogs: NameInUse→`ID_Character_Err_NameReserved`, -NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→ -`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→ -`ID_Character_Err_NameAdminDenied`, Pending/Undef→silent state reset (ACE -sends Pending for a disabled-Olthoi rejection — retail swallows it; port -as-is, register-note the quirk). +never appears). Error dialogs, byte-decoded from +`gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030`'s +switch + its `(arg2-1) > 6` unsigned-underflow guard and jump table +`@0x004e9150` (CC5 review-fix round F2, 2026-08-16 — corrects this +paragraph's earlier "Pending/Undef→silent state reset, retail swallows it" +claim, which was WRONG): Ok(1)→no dialog (closes any open dialog, marks +success, returns); **Pending(2)→`ID_Character_Err_NameDBDown`** (explicit +switch case, same label as Corrupt/DatabaseDown — NOT a silent reset); +NameInUse(3)→`ID_Character_Err_NameReserved`; NameBanned(4)→ +`ID_Character_Err_NameBanned`; Corrupt(5)/DatabaseDown(6)→ +`ID_Character_Err_NameDBDown`; AdminPrivilegeDenied(7)→ +`ID_Character_Err_NameAdminDenied`; **Undef(0) and any code outside 1..7 +fall through the unsigned-underflow default arm to the SAME +`ID_Character_Err_NameDBDown` dialog** (the switch never has a genuinely +silent branch — every non-Ok code shows a dialog). ACE sends Pending for a +disabled-Olthoi rejection (`CharacterHandler.CharacterCreateEx`, +`olthoi_play_disabled` branch); ported faithfully this now means that +rejection surfaces a visible NameDBDown dialog, which IS retail's actual +behavior — the previous "swallows it" reading made Finish a silent +no-op forever for that case instead. **Chargen DAT table** `0x0E000002`: readable TODAY via the Chorizite.DatReaderWriter package (`dats.Get`) — zero in-tree @@ -252,7 +266,7 @@ the user gate. | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | -| CC5 | CODE-COMPLETE 2026-08-15 | `34e3a534` | OWED (dual-lens review pending) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied` (Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` already treats them as a silent reset with no `RuntimeCharacterCreationRejection` produced at all); dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | +| CC5 | CODE-COMPLETE 2026-08-15 | `34e3a534`, `a975efd1` (ledger) + this fix-round commit | fix round landed F1-F14, narrow re-review pending | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index c24fcbeb..ec368803 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -26,6 +26,7 @@ using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; using DatReaderWriter; +using DatReaderWriter.DBObjs; using Silk.NET.Input; using Silk.NET.Windowing; @@ -656,6 +657,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // outside the lock matches this file's existing pattern // elsewhere (construct once, lock only around Resolve calls). var characterCreationStrings = new DatStringResolver(d.Dats); + // CC5 review fix round F3 (2026-08-16): read the global + // SkillTable (portal.dat 0x0E000004 — the SAME file + // ChargenOptions.GlobalSkillCostsBySkillId's own doc comment and + // LiveSessionRuntimeFactory.CreateCharacterBindings already read) + // ONCE at composition time, under the DatLock DatCollection's + // thread-safety contract requires — mirrors LiveSkillCreditResolver's + // own constructor-time load. The resolver itself does no further + // DAT access per call (pure SkillFormula arithmetic), so the + // Summary page's GetSkillScore binding below needs no lock. + SkillTable? chargenSkillTable; + lock (d.DatLock) + chargenSkillTable = d.Dats.Get(0x0E000004u); + var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable); var bindings = new RetailUiRuntimeBindings( Host: host, Assets: assets, @@ -1012,6 +1026,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter, RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, + GetSkillScore: chargenSkillScoreResolver.Resolve, OpenOnStart: d.Options.OpenCharacterCreationOnStart) : null); RetailUiRuntime runtime = lease.Mount( diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index a623d31e..456baf4b 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1112,14 +1112,17 @@ internal sealed class LivePresentationCompositionPhase // (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE // instance from the Appearance page's own during the CC6b-MOUNT // review) — same one-shot binding shape as the Appearance preview - // immediately above (AP-221's own disposition applies here too: a - // DAT/resource read not ready on this exact composition frame means - // the Summary preview stays permanently unbound for the session, - // same tracked follow-up as the Appearance preview). No zoom/rotate - // control surface is wired — retail's Summary page has no such - // buttons (only StartAnimation's idle loop and a fixed 180° - // heading), so this controller's ZoomIn/RotateClockwise etc. simply - // never get called. + // immediately above. Review fix round F7 (2026-08-16): AP-221 is now + // AMENDED to cover this second binding explicitly (it originally + // named CC5 as the slice that should CLOSE the gap; CC5 duplicated + // the pattern here instead) — a DAT/resource read not ready on this + // exact composition frame means the Summary preview stays + // permanently unbound for the session, same tracked follow-up as + // the Appearance preview, now under the same amended row. No + // zoom/rotate control surface is wired — retail's Summary page has + // no such buttons (only StartAnimation's idle loop and a + // fixed 180° heading), so this controller's ZoomIn/RotateClockwise + // etc. simply never get called. CompositionAcquisitionScope.CompositionAcquisitionLease< ChargenPreviewRenderer>? summaryPreviewLease = null; ChargenPreviewController? summaryPreviewController = null; @@ -1162,7 +1165,12 @@ internal sealed class LivePresentationCompositionPhase content.AnimationLoader, summaryCatalog, summaryCatalog, - d.DatLock); + d.DatLock, + // F5 (2026-08-16): the Summary preview is retail's zoomed- + // OUT full-body framing (gmCGSummaryPage::InitializePage @ + // 0x0047bbf0), not the Appearance page's zoomed-in default — + // see ChargenPreviewController's own ctor doc comment. + useZoomedOutEye: true); interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController; bindings.AdoptRelease( "summary preview control", diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index 72688bae..8ae59ba3 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -1,3 +1,4 @@ +using AcDream.Core.CharGen; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -32,6 +33,45 @@ internal static class RetailSkillFormula result = (uint)Math.Floor((double)numerator / divisor + 0.5d); return true; } + + /// + /// Campaign CC CC5 review fix round, F3 (2026-08-16). Ports + /// CharGenState::GetSkillScore @ 0x005C4B50's FULL behavior, not + /// just the shared base: after the formula + /// result, retail adds a level-based bonus keyed off the skill's CURRENT + /// advancement class (edi_1 in the decomp) — edi_1 == 2 + /// (Trained) → result += 5; edi_1 == 3 (Specialized) → + /// result += 10 — before returning. The decomp's own gate, + /// if (edi_1 >= var_38) (var_38 resolves to + /// SkillBase.MinLevel — a decompiler-mangled local the raw + /// pseudo-C renders as an uninitialized read; DatReaderWriter's own + /// typed SkillBase.MinLevel field is the same value cleanly), is + /// satisfied for both callers of this method (Specialized=3 and + /// Trained=2 are the only two advancement classes CC5's Summary listbox + /// still shows — AP-224 — and no retail-authored skill sets + /// MinLevel above Untrained=1) so it is not reproduced as a + /// separate branch; a future caller passing + /// or would need + /// that gate ported for real. + /// + public static uint CalculateChargenScore( + SkillBase skillBase, + uint attribute1, + uint attribute2, + ChargenSkillAdvancementClass level) + { + ArgumentNullException.ThrowIfNull(skillBase); + + if (!TryCalculate(skillBase.Formula, attribute1, attribute2, out uint result)) + return 0u; + + return level switch + { + ChargenSkillAdvancementClass.Trained => result + 5u, + ChargenSkillAdvancementClass.Specialized => result + 10u, + _ => result, + }; + } } /// @@ -70,3 +110,54 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable) : 0u; } } + +/// +/// Campaign CC CC5 review fix round, F3 (2026-08-16). Chargen-side sibling +/// of : resolves +/// against the SAME +/// global SkillTable (portal.dat 0x0E000004), fed by a +/// candidate character's CHARGEN attribute spread (, +/// keyed the same way AcDream.Runtime.Session.ChargenAttributeId +/// already does — verified against DatReaderWriter's own +/// DatReaderWriter.Enums.AttributeId generated enum, which carries the +/// identical Strength=1/Endurance=2/Quickness=3/Coordination=4/Focus=5/ +/// Self=6 numbering) rather than a live player's server-echoed current +/// attributes. Wired at composition time +/// (InteractionRetainedUiComposition.cs) so +/// CharacterCreationSummaryPage never needs a DAT/Chorizite +/// dependency of its own — same shape as that composition's existing +/// ResolveText binding. +/// +internal sealed class ChargenSkillScoreResolver(SkillTable? skillTable) +{ + public uint Resolve( + uint skillId, + ChargenAttributeValues attributes, + ChargenSkillAdvancementClass level) + { + if (skillTable?.Skills is null + || !skillTable.Skills.TryGetValue( + (DatReaderWriter.Enums.SkillId)skillId, + out var skillBase)) + { + return 0u; + } + + uint attribute1 = ResolveAttribute(skillBase.Formula.Attribute1, attributes); + uint attribute2 = ResolveAttribute(skillBase.Formula.Attribute2, attributes); + return RetailSkillFormula.CalculateChargenScore(skillBase, attribute1, attribute2, level); + } + + private static uint ResolveAttribute( + DatReaderWriter.Enums.AttributeId attributeId, + ChargenAttributeValues attributes) => attributeId switch + { + DatReaderWriter.Enums.AttributeId.Strength => (uint)Math.Max(0, attributes.Strength), + DatReaderWriter.Enums.AttributeId.Endurance => (uint)Math.Max(0, attributes.Endurance), + DatReaderWriter.Enums.AttributeId.Quickness => (uint)Math.Max(0, attributes.Quickness), + DatReaderWriter.Enums.AttributeId.Coordination => (uint)Math.Max(0, attributes.Coordination), + DatReaderWriter.Enums.AttributeId.Focus => (uint)Math.Max(0, attributes.Focus), + DatReaderWriter.Enums.AttributeId.Self => (uint)Math.Max(0, attributes.Self), + _ => 0u, + }; +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index a0c16d53..edab1fc5 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -178,6 +178,7 @@ internal sealed class ChargenPreviewController : private readonly IChargenPalSetSource _palSets; private readonly IChargenClothingTableSource _clothingTables; private readonly object _datLock; + private readonly bool _useZoomedOutEye; private readonly Stopwatch _clock = Stopwatch.StartNew(); private ChargenPreviewAnimator? _animator; @@ -193,6 +194,19 @@ internal sealed class ChargenPreviewController : /// 's own camera constructor /// parameter — see this class's own doc comment on why the renderer and /// the zoom controller must share one mutable camera. + /// Review fix round F5 (2026-08-16): + /// (the default) reproduces the Appearance + /// page's own zoomed-IN default eye + /// (gmCGAppearancePage::InitializePage @ 0x0047FDD0, + /// ). + /// reproduces the Summary page's own eye + /// (gmCGSummaryPage::InitializePage @ 0x0047bbf0, byte-decoded + /// eye literal (0, -2.5, 0.95) at ~0x0047bd14-0x0047bd44 — + /// exactly 's + /// default-heritage value, NOT the zoomed-in one this controller used + /// before the fix). Summary has no zoom buttons at all (retail's own + /// viewport there is fixed-framing), so this is a permanent camera + /// profile for the controller's whole lifetime, not a toggle. public ChargenPreviewController( IChargenPreviewRenderer renderer, ChargenPreviewCamera camera, @@ -201,7 +215,8 @@ internal sealed class ChargenPreviewController : IAnimationLoader animations, IChargenPalSetSource palSets, IChargenClothingTableSource clothingTables, - object datLock) + object datLock, + bool useZoomedOutEye = false) { _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _camera = camera ?? throw new ArgumentNullException(nameof(camera)); @@ -211,7 +226,15 @@ internal sealed class ChargenPreviewController : _palSets = palSets ?? throw new ArgumentNullException(nameof(palSets)); _clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + _useZoomedOutEye = useZoomedOutEye; _rotation = new ChargenPreviewRotationController(); + // Seed the eye NOW, matching whatever the first Rebuild's own + // heritageOrGenderChanged branch below would otherwise defer until + // the first successful compose — avoids one frame of the wrong + // (Appearance-profile) eye if this controller ever renders before + // Rebuild's first call succeeds. + if (_useZoomedOutEye) + _camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye(0u); } /// Test-observability seam only — production callers use @@ -275,7 +298,14 @@ internal sealed class ChargenPreviewController : bool heritageOrGenderChanged = !_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey; if (heritageOrGenderChanged) - _camera.SetHeritage(heritageId); + { + // F5: the Summary controller (_useZoomedOutEye) re-derives the + // FIXED zoomed-out eye per heritage instead of SetHeritage's + // zoomed-in default — see the ctor param's own doc comment. + _camera.Eye = _useZoomedOutEye + ? ChargenPreviewCamera.ResolveZoomedOutEye(heritageId) + : ChargenPreviewCamera.ResolveDefaultEye(heritageId); + } // ChargenPreviewZoomController's animator dependency is required at // construction (fix round F2) — a fresh animator means a fresh diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs index d95dfdc8..94545683 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -56,17 +56,24 @@ internal sealed class CharacterCreationSummaryPage : IDisposable private const uint KeyTextId = 0x100002FCu; private const uint ValueTextId = 0x100002FDu; - /// Retail's name[33] buffer (32 usable chars + null - /// terminator — RuntimeCharacterCreationState.TrySetName's own - /// already-established storage cap). The decompiled UI-side check at - /// ListenToElementMessage @ 0x0047bfd1 compares the raw input - /// length against the literal 0x21 (33) — one more than this — - /// but that comparison's exact base (visible character count vs. an - /// internal length-prefix accounting the decompiler didn't resolve - /// cleanly) is not fully certain from the pseudo-C. Using 32 here keeps - /// the UI-level reject-and-revert threshold CONSISTENT with the - /// already-reviewed storage cap rather than trusting an ambiguous - /// 1-off decomp literal over that established contract. + /// + /// Retail's name[33] buffer (32 usable chars + null terminator — + /// RuntimeCharacterCreationState.TrySetName's own + /// already-established storage cap). Review fix round F6 (2026-08-16): + /// the decompiled UI-side check at ListenToElementMessage @ + /// 0x0047bf40 (~0x0047bfd1) compares the field text's + /// m_charbuffer LENGTH FIELD against the literal 0x21 + /// (33) — that field is confirmed NUL-INCLUSIVE (the SAME method's own + /// empty-field check earlier at 0x0047bf93 compares that field to + /// 1, i.e. an empty string's length reads as 1, not 0). So + /// length > 33 is EXACTLY visibleChars > 32: a + /// 32-character name has length 33 (not > 33, accepted), a + /// 33-character name has length 34 (> 33, rejected). This + /// constant was ALWAYS byte-correct, not merely internally consistent + /// with the storage cap it was originally justified against — the + /// earlier "not fully certain" hedge and its AP-225 register row are + /// both retired. + /// private const int MaxNameLength = 32; private readonly CharacterCreationRuntimeBindings _bindings; @@ -75,7 +82,6 @@ internal sealed class CharacterCreationSummaryPage : IDisposable private readonly UiTemplateListBox? _list; private readonly UiField? _nameField; private string _lastCommittedName = string.Empty; - private bool _suppressNextFieldEvent; private uint _nameTooLongDialogContext; private bool _disposed; @@ -92,13 +98,27 @@ internal sealed class CharacterCreationSummaryPage : IDisposable UiElement pageRoot, CharacterCreationRuntimeBindings bindings, RetailDialogFactory dialogs, - string nameTooLongMessage) + string nameTooLongMessage, + Func templateResolver) { _bindings = bindings; _dialogs = dialogs; _nameTooLongMessage = nameTooLongMessage; _list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox; + // Review fix round F3 residual (found while adding its own test, + // 2026-08-16): this assignment was MISSING outright — every sibling + // page that owns a UiTemplateListBox (CharacterCreationSkillsPage, + // CharacterManagementUiController, every Options-panel controller) + // wires TemplateResolver in its own constructor; this page never + // did. Without it, ResolveTemplateRow's own `_list.TemplateResolver + // is null` guard made EVERY RebuildListbox call a silent no-op — + // the Summary listbox has never rendered a single row (Profession/ + // Gender/Heritage/Town, Attributes, Health/Stamina/Mana/Skill + // Credits, or the skill buckets) since CC5 shipped, independent of + // and masking the F3 template/score fix above. + if (_list is not null) + _list.TemplateResolver = templateResolver; _nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField; if (_nameField is not null) @@ -137,10 +157,19 @@ internal sealed class CharacterCreationSummaryPage : IDisposable // Keep the field's displayed text in sync with the committed name // unless the player is actively typing (a mid-edit Refresh — driven // by an unrelated selection change elsewhere on the screen — must - // not clobber their in-progress keystrokes). + // not clobber their in-progress keystrokes). Review fix round F1 + // (2026-08-16): this used to arm a "_suppressNextFieldEvent" latch + // before calling SetText, on the assumption that SetText raises the + // same commit event a real keystroke/blur would. It does not — + // UiField.SetText (UiField.cs:240-248) only mutates _text/_caret and + // never invokes OnFocusLost/OnSubmit (those fire exclusively from + // OnEvent's own idMessage dispatch, UiField.cs:~313-316/:729). The + // latch therefore never had anything genuine to suppress; it just + // sat armed until the PLAYER's own next real commit, which then hit + // this early-return and silently dropped their typed name. Deleting + // the latch outright (nothing to reproduce) fixes that bug. if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name) { - _suppressNextFieldEvent = true; field.SetText(snapshot.Name); _lastCommittedName = snapshot.Name; } @@ -151,15 +180,35 @@ internal sealed class CharacterCreationSummaryPage : IDisposable // ── Name field (ListenToElementMessage @ 0x0047bf40) ──────────────── + /// + /// Review fix round F9 (2026-08-16), empty-name commit: retail's + /// ListenToElementMessage @ ~0x0047bf93 reads a length field that + /// is NUL-INCLUSIVE (confirmed at F6/F2's own byte-decode — an empty + /// field's length is 1, not 0) and gates the ENTIRE commit block — + /// including SetName — behind if (length != 1). Blurring + /// an EMPTIED field in retail therefore leaves CharGenState.name + /// UNCHANGED (whatever it held before), not cleared; DoFinish + /// later reads that unchanged internal name, so retail's field and its + /// internal state can legitimately show different things after an + /// empty-field blur. This port deliberately does NOT reproduce that: + /// it calls + /// (line below) for every commit including an empty one, so the state + /// always agrees with what the field just showed. Verified this is a + /// genuine, not cosmetic, choice — porting the exact skip would fight + /// 's own field-sync block above (the F1 fix): the + /// NEXT time anything else bumps the Runtime revision (e.g. the player + /// returns to Attributes and changes a slider, then comes back), Refresh + /// would see field.Text ("") != snapshot.Name (the stale unchanged + /// name) and forcibly restore the OLD name into the field — a + /// spontaneous, unexplained repopulation of a field the player + /// deliberately emptied, which retail's own non-continuously-refreshed + /// UI never produces. Register AP-227 records this as a deliberate + /// divergence. + /// private void CommitNameFromField(string text) { if (_disposed) return; - if (_suppressNextFieldEvent) - { - _suppressNextFieldEvent = false; - return; - } if (text.Length > MaxNameLength) { @@ -236,8 +285,8 @@ internal sealed class CharacterCreationSummaryPage : IDisposable AddPair(pairTemplate, "Mana", a.Self); AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits); - AddSkillBucket(headerTemplate, lineTemplate, view, "Specialized Skills", ChargenSkillAdvancementClass.Specialized); - AddSkillBucket(headerTemplate, lineTemplate, view, "Trained Skills", ChargenSkillAdvancementClass.Trained); + AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Specialized Skills", ChargenSkillAdvancementClass.Specialized); + AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Trained Skills", ChargenSkillAdvancementClass.Trained); } private void AddLine(UiTemplateListEntry template, string text) @@ -283,24 +332,33 @@ internal sealed class CharacterCreationSummaryPage : IDisposable return row is null ? null : UiElement.FindDescendant(row, childId) as UiText; } + /// + /// Review fix round F3 (2026-08-16), byte-decoded against + /// SetSummaryText @ ~0x0047b6be-0x0047b9e0: retail adds each + /// bucket's HEADER row UNCONDITIONALLY, before it ever walks + /// skillRecordList for that bucket (an empty bucket still shows + /// its header) — the previous lazy "only if any skill matched" gate had + /// no decomp support. Each matching skill row uses template 2 (the + /// key/value pair, AddItemFromTemplateList(..., 2, ...) @ + /// 0x0047b938), not template 0's single line — KEY = the skill + /// name, VALUE = CharGenState::GetSkillScore(state, skill->id) @ + /// 0x0047b923, ported as . + /// private void AddSkillBucket( UiTemplateListEntry headerTemplate, - UiTemplateListEntry lineTemplate, + UiTemplateListEntry pairTemplate, IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot, string header, ChargenSkillAdvancementClass targetClass) { - bool any = false; + AddHeader(headerTemplate, header); for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) { if (view.GetSkillLevel(skillId) != targetClass) continue; - if (!any) - { - AddHeader(headerTemplate, header); - any = true; - } - AddLine(lineTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId)); + uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, targetClass) ?? 0u; + AddPair(pairTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId), (int)score); } } @@ -364,6 +422,8 @@ internal sealed class CharacterCreationSummaryPage : IDisposable _nameTooLongDialogContext = 0u; _dialogs.CloseDialog(closing); } + if (_list is not null) + _list.TemplateResolver = null; _list?.Flush(); // PreviewControl is owned by the composition root (disposed with // the leased ChargenPreviewRenderer) — just drop the reference. diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index d74a0462..bf564a55 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -61,6 +61,13 @@ public sealed record CharacterCreationRuntimeBindings( /// CC5: the Appearance page's Random button on its Clothes /// sub-tab. Func? RandomizeClothing = null, + /// CC5 review fix round, F3 (2026-08-16): the Summary page's + /// skill-row VALUE — CharGenState::GetSkillScore @ 0x005C4B50, + /// wired at composition time (AcDream.App.Net.ChargenSkillScoreResolver) + /// so this UI-layer record stays free of a direct DAT/Chorizite + /// dependency, matching 's own shape. + /// degrades to a "no score available" 0. + Func? GetSkillScore = null, bool OpenOnStart = false); /// @@ -188,6 +195,13 @@ internal sealed class CharacterCreationUiController : IDisposable private uint _creditWarningDialogContext; private uint _randomizeWarningDialogContext; private uint _noNameWarningDialogContext; + // CC5 review fix round F4 (2026-08-16): gmCharGenMainUI's own + // m_uiErrorMessageContext (MakeErrorMessageDialog @ 0x004e8cb0's guard + // at 0x004e8cc4, assigned at 0x004e8dd3, cleared by the dtor at + // 0x004e83b3 alongside m_uiPleaseWaitContext/m_uiExitContext) — the + // 0xF643 rejection dialog was the only one of the five dialogs this + // controller owns without this same one-outstanding-dialog guard. + private uint _errorMessageDialogContext; private RuntimeCharacterCreationRejection? _lastShownRejection; private bool _suppressDialogCallbacks; private bool _disposed; @@ -270,7 +284,7 @@ internal sealed class CharacterCreationUiController : IDisposable _townPage = new CharacterCreationTownPage(townPageRoot, bindings); _appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings); _summaryPage = new CharacterCreationSummaryPage( - summaryPageRoot, bindings, dialogs, strings.NameTooLong); + summaryPageRoot, bindings, dialogs, strings.NameTooLong, templateResolver); // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. _back.OnClick = OnBack; @@ -929,16 +943,21 @@ internal sealed class CharacterCreationUiController : IDisposable // ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ── // ── 0x0055E8B0) ────────────────────────────────────────────────────── - /// Ports the four rejection-dialog mappings from - /// Handle_CharGenVerificationResponse's per-case switch (restated - /// on 's own doc - /// comment); Pending/Undef never reach this method (CC3's - /// ApplyCreationResponse treats them as a silent state reset with - /// no produced at all). - /// Dedups against the LAST rejection instance already shown so a - /// same-value re-check on a later (this method runs - /// every tick, not just on revision change) doesn't reopen the dialog - /// the player already dismissed. + /// + /// Ports the COMPLETE rejection-dialog mapping from + /// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @ + /// 0x004e9030's own switch + its (arg2-1) > 6 + /// unsigned-underflow default arm (restated on + /// 's own doc comment). + /// CC5 review-fix round F2 (2026-08-16): every non-Ok code now reaches + /// this method (RuntimeCharacterCreationState.ApplyCreationResponse + /// no longer special-cases Pending/Undef as a silent reset) and every + /// branch here resolves to a real dialog — retail's dispatch has NO + /// silent case. Dedups against the LAST rejection instance already + /// shown so a same-value re-check on a later (this + /// method runs every tick, not just on revision change) doesn't reopen + /// the dialog the player already dismissed. + /// private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot) { RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection; @@ -951,24 +970,42 @@ internal sealed class CharacterCreationUiController : IDisposable return; _lastShownRejection = rejection; - string? key = rejection.Value.Code switch + // MakeErrorMessageDialog's own guard @0x004e8cc4: a context already + // open is a no-op (the SECOND rejection's dialog is silently + // dropped, not queued) — F4's fix, matching the four sibling + // dialogs' shape. _lastShownRejection is already updated above even + // when this guard blocks the dialog, which is retail-faithful: a + // later Tick with the SAME rejection value must not retry it either + // (this scenario is not reachable through the ordinary UI today — + // TryBeginFinish's AlreadyPending refusal means a second Finish + // cannot land while a rejection is still unacknowledged — but the + // guard exists so the SHAPE matches retail's even if a future + // caller reaches it). + if (_errorMessageDialogContext != 0u) + return; + + // Pending/Corrupt/DatabaseDown are explicit switch cases in retail's + // own dispatch landing on the SAME "ID_Character_Err_NameDBDown" + // label; Undef and any code outside 1..7 fall through that + // function's unsigned-underflow default arm to the identical label + // — the `_` arm below is that default, not a "no dialog" case. + string key = rejection.Value.Code switch { CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved", CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned", - CharGenVerificationResponse.Code.Corrupt - or CharGenVerificationResponse.Code.DatabaseDown => "ID_Character_Err_NameDBDown", CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied", - _ => null, + _ => "ID_Character_Err_NameDBDown", }; - if (key is null) - return; string? message = _bindings.ResolveText?.Invoke(key); if (message is null) return; - _dialogs.MakeMessage(message, data => + _errorMessageDialogContext = _dialogs.MakeMessage(message, data => { + _errorMessageDialogContext = 0u; _ = data; + if (_disposed || _suppressDialogCallbacks) + return; _bindings.AcknowledgeRejection?.Invoke(); }); } @@ -1014,6 +1051,12 @@ internal sealed class CharacterCreationUiController : IDisposable _noNameWarningDialogContext = 0u; _dialogs.CloseDialog(closing); } + if (_errorMessageDialogContext != 0u) + { + uint closing = _errorMessageDialogContext; + _errorMessageDialogContext = 0u; + _dialogs.CloseDialog(closing); + } } finally { diff --git a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs index 11eab682..1640b143 100644 --- a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs +++ b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs @@ -56,18 +56,27 @@ namespace AcDream.Core.Net.Messages; /// CharacterGenerationVerificationResponse enum /// (ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs), /// which is itself retail's own dialog dispatch table -/// (Handle_CharGenVerificationResponse@0x0055E8B0): NameInUse → -/// ID_Character_Err_NameReserved, NameBanned → -/// ID_Character_Err_NameBanned, Corrupt/DatabaseDown → -/// ID_Character_Err_NameDBDown, AdminPrivilegeDenied → -/// ID_Character_Err_NameAdminDenied. Pending/Undef -/// retail treats as a silent state reset with no dialog — notably ACE sends +/// (Handle_CharGenVerificationResponse@0x0055E8B0 + +/// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse@0x004e9030's +/// own jump table). CC5 review-fix round F2 (2026-08-16) correction: +/// every non-Ok code shows a dialog — there is no silent branch. +/// NameInUseID_Character_Err_NameReserved, +/// NameBannedID_Character_Err_NameBanned, +/// AdminPrivilegeDeniedID_Character_Err_NameAdminDenied, +/// and Pending/Corrupt/DatabaseDown/Undef/any +/// unrecognized code ALL resolve to ID_Character_Err_NameDBDown — +/// Pending is an explicit switch case landing on that same label, +/// and Undef/out-of-range falls through +/// RecvNotice_CharGenVerificationResponse's own +/// (arg2-1) > 6 unsigned-underflow default arm to the identical +/// label. This corrects an earlier (wrong) reading of the decomp that +/// treated Pending/Undef as a silent state reset — notably ACE sends /// Pending for a disabled-Olthoi rejection /// (CharacterHandler.CharacterCreateEx, -/// olthoi_play_disabled branch), so that specific rejection is -/// invisible to the retail-faithful client too; this is a retail quirk to -/// port as-is, not a bug to fix. Dialog presentation itself is CC5's job -/// (App layer), not this Core.Net type's. +/// olthoi_play_disabled branch), so that specific rejection now +/// correctly surfaces the NameDBDown dialog, matching retail, instead of +/// silently resetting verification state. Dialog presentation itself is +/// CC5's job (App layer), not this Core.Net type's. /// /// public static class CharGenVerificationResponse diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 3815b305..ecb985a8 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -19,7 +19,18 @@ public sealed record GameRuntimeDependencies( ILiveSessionOperations? SessionOperations = null, Func? CombatTime = null, uint FirstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, - int MaximumChatEntries = 500); + int MaximumChatEntries = 500, + // Review fix round F13 (2026-08-16): the shared RNG source for + // process-wide randomize commands reachable from a headless bot + // (RuntimeCharacterCreationState's Randomize* family — + // RandomizeCharacter/RandomizeAppearance/RandomizeClothing, exposed on + // IRuntimeCharacterCreationCommands). null (the default) keeps every + // existing caller's production behavior unchanged (Random.Shared, + // threaded the same way TimeProvider already is here) — this only + // exists so a future deterministic-bot config (Slice K's contract, + // project_linux_headless_bots.md) can supply a seeded Random without a + // second construction path. + Random? Random = null); [Flags] public enum GameRuntimeTeardownStage @@ -180,10 +191,12 @@ public sealed class GameRuntime context.Session = dependencies.SessionOperations is null ? new LiveSessionController( ProductionLiveSessionOperations.Instance, - dependencies.TimeProvider) + dependencies.TimeProvider, + random: dependencies.Random) : new LiveSessionController( dependencies.SessionOperations, - dependencies.TimeProvider); + dependencies.TimeProvider, + random: dependencies.Random); construction.Own(context.Session); Fault( GameRuntimeConstructionPoint.SessionCreated, diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 12a49065..756bc85f 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -455,7 +455,13 @@ public sealed class LiveSessionController public LiveSessionController( ILiveSessionOperations operations, TimeProvider? timeProvider = null, - ChargenOptions? chargenOptions = null) + ChargenOptions? chargenOptions = null, + // Review fix round F13 (2026-08-16): threaded from + // GameRuntimeDependencies.Random the same way timeProvider already + // is — null keeps every existing caller (including this class's own + // parameterless ctor below) on RuntimeCharacterCreationState's own + // Random.Shared default. + Random? random = null) { _operations = operations ?? throw new ArgumentNullException(nameof(operations)); CharacterSelectionState = new RuntimeCharacterSelectionState( @@ -466,7 +472,7 @@ public sealed class LiveSessionController // not this one. A caller that never supplies real options simply // gets an inert chargen surface (every heritage lookup misses). CharacterCreationState = new RuntimeCharacterCreationState( - chargenOptions ?? ChargenOptions.Empty); + chargenOptions ?? ChargenOptions.Empty, random); } public RuntimeCharacterSelectionState CharacterSelectionState { get; } diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index 701a3bf8..1bf44ce6 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -163,18 +163,25 @@ public readonly record struct RuntimeCharacterCreationIdentity( /// /// A non-Ok 0xF643 response, mapped to retail's dialog family -/// (Handle_CharGenVerificationResponse @ 0x0055E8B0's per-case dialog -/// dispatch, restated in 's doc -/// comment): → -/// NameReserved, → -/// NameBanned, / -/// → NameDBDown, -/// → -/// NameAdminDenied. / -/// never produce this -/// record — retail treats them as a silent state reset with no dialog (ACE -/// sends Pending for a disabled-Olthoi rejection; this is a genuine -/// retail quirk, not a bug — port as-is). +/// (Handle_CharGenVerificationResponse @ 0x0055E8B0 + +/// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @ +/// 0x004e9030's own switch/jump-table dispatch, restated in +/// 's doc comment). +/// CC5 review-fix round F2 (2026-08-16) correction: ALL non-Ok codes +/// produce this record — +/// → NameReserved, → +/// NameBanned, → +/// NameAdminDenied, and / +/// / +/// / +/// / any unrecognized +/// code ALL → NameDBDown (Pending is an explicit switch case landing on +/// that same label; Undef/out-of-range falls through the function's own +/// unsigned-underflow default arm to the identical label). An earlier +/// reading of the decomp treated Pending/Undef as producing a silent state +/// reset with NO record and no dialog — that was wrong; retail's dispatch +/// has no silent branch (ACE sends Pending for a disabled-Olthoi +/// rejection, which now correctly surfaces the NameDBDown dialog). /// public readonly record struct RuntimeCharacterCreationRejection( uint RawCode, @@ -1253,6 +1260,27 @@ public sealed class RuntimeCharacterCreationState : IDisposable return result; } + /// + /// Ports CharGenState::GetRandomReal @ 0x00563940 exactly: + /// (double)rand() * (1.0/32767.0). Review fix round F8 + /// (2026-08-16), byte-decoded from the raw PE: the pseudo-C shows only + /// return rand(this); (the decompiler elided the FPU multiply + /// entirely), but the actual machine code is + /// call rand; fild [esp]; fmul qword ptr [0x007cd650]; ret — an + /// 8-BYTE double-precision operand (fmul qword, not dword). + /// The bytes at 0x007cd650 are 80 00 40 00 20 00 00 3f, + /// which as a little-endian IEEE-754 double is EXACTLY + /// 1.0/32767.0 (bit pattern 0x3f00002000400080) — NOT + /// 1.0/32768.0 (which would be 0x3f00000000000000), a + /// prior narrower reading corrects here. Retail's CRT rand() + /// returns [0, RAND_MAX] with RAND_MAX == 0x7FFF == 32767 + /// (MSVC), so the shade roll is a 32768-point lattice on + /// [0.0, 1.0] INCLUSIVE (both endpoints reachable) — + /// 's continuous [0, 1) is a + /// different distribution entirely. + /// + private double RollShadeLocked() => _random.Next(32768) * (1.0 / 32767.0); + /// Ports CharGenState::RandomizeAppearance(this, 0) @ /// 0x005c4f10 — every real call site in the retail binary passes /// arg2 == 0 (an exhaustive grep of every RandomizeAppearance @@ -1261,8 +1289,8 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// code and is not ported. Each field is only rolled when its list is /// non-empty (retail's own per-field if (count != 0) guards); /// skinShade/hairShade are vtable->GetRandomReal() - /// — the SAME rand()*(1/32768) uniform-[0,1) shade roll every - /// other Randomize* function below uses explicitly inline. + /// — the SAME shade roll every other + /// Randomize* function below uses. private void RandomizeAppearanceLocked() { if (!TryGetGenderOptionsLocked(out ChargenGenderOptions? gender)) @@ -1275,7 +1303,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable a = a with { NoseStrip = RandomizeIndexExcludingLocked(gender.NoseStrips.Count, a.NoseStrip) }; if (gender.MouthStrips.Count > 0) a = a with { MouthStrip = RandomizeIndexExcludingLocked(gender.MouthStrips.Count, a.MouthStrip) }; - a = a with { SkinShade = _random.NextDouble(), HairShade = _random.NextDouble() }; + a = a with { SkinShade = RollShadeLocked(), HairShade = RollShadeLocked() }; if (gender.HairColors.Count > 0) a = a with { HairColor = RandomizeIndexExcludingLocked(gender.HairColors.Count, a.HairColor) }; if (gender.EyeColors.Count > 0) @@ -1328,7 +1356,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable HeadgearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.HeadgearColor), }; } - _appearance = _appearance with { HeadgearShade = _random.NextDouble() }; + _appearance = _appearance with { HeadgearShade = RollShadeLocked() }; } /// Ports CharGenState::RandomizeShirt @ 0x005c5ef0 — @@ -1354,7 +1382,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable ShirtColor = RandomizeIndexExcludingLocked(colorCount, _appearance.ShirtColor), }; } - _appearance = _appearance with { ShirtShade = _random.NextDouble() }; + _appearance = _appearance with { ShirtShade = RollShadeLocked() }; } /// Ports CharGenState::RandomizeTrousers @ 0x005c5fb0. @@ -1378,7 +1406,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable TrousersColor = RandomizeIndexExcludingLocked(colorCount, _appearance.TrousersColor), }; } - _appearance = _appearance with { TrousersShade = _random.NextDouble() }; + _appearance = _appearance with { TrousersShade = RollShadeLocked() }; } /// Ports CharGenState::RandomizeFootwear @ 0x005c6070. @@ -1402,7 +1430,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable FootwearColor = RandomizeIndexExcludingLocked(colorCount, _appearance.FootwearColor), }; } - _appearance = _appearance with { FootwearShade = _random.NextDouble() }; + _appearance = _appearance with { FootwearShade = RollShadeLocked() }; } /// Ports CharGenState::RandomizeClothing(this, arg2) @ @@ -1486,6 +1514,18 @@ public sealed class RuntimeCharacterCreationState : IDisposable ClearSessionState(); uint heritageId = (uint)RollDiceLocked(1, 4); + // Review fix round F14 (2026-08-16): retail's SetHeritageGroup @ + // 0x005C67A0 writes `this->mHeritageGroup = arg2;` UNCONDITIONALLY + // as its very first statement, before the DAT lookup + // (ACCharGenData::GetHG) that gates the credit/template/start-area + // recompute. Assign the raw field here too, before the + // TryGetHeritage gate below, so a hypothetical DAT-lookup miss + // leaves `_heritageId` set (matching retail's unconditional write) + // instead of the previous half-state where heritage stayed 0 while + // SetGenderLocked below still ran and produced a real gender. + // Unreachable with the installed DAT — every rolled id 1..4 always + // resolves — this is defensive shape-parity only. + _heritageId = heritageId; if (_options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) SetHeritageGroupLocked(heritageId, heritage); @@ -1765,11 +1805,23 @@ public sealed class RuntimeCharacterCreationState : IDisposable } /// - /// Ports the four rejection dialog mappings + the silent - /// Pending/Undef reset from Handle_CharGenVerificationResponse @ - /// 0x0055E8B0. Idempotent-tolerant to a second, unrequested Ok/reject - /// while nothing is pending (ACE's own double-NameInUse quirk, CC2 - /// review F3) — a call that arrives while + /// Ports the full rejection-dialog dispatch from + /// Handle_CharGenVerificationResponse @ 0x0055E8B0 + + /// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @ + /// 0x004e9030's own jump table. CC5 review-fix round F2 + /// (2026-08-16): every non-Ok code produces a + /// — there is no silent + /// branch. Pending previously short-circuited as a bare state reset + /// with no rejection produced; that was a misreading of the decomp + /// (Pending is an explicit switch case in + /// RecvNotice_CharGenVerificationResponse landing on the SAME + /// ID_Character_Err_NameDBDown label as Corrupt/DatabaseDown, + /// and Undef/any out-of-range code falls through that function's own + /// (arg2-1) > 6 unsigned-underflow default arm to the + /// identical label) — see the else branch's own inline comment + /// for the full citation. Idempotent-tolerant to a second, unrequested + /// Ok/reject while nothing is pending (ACE's own double-NameInUse + /// quirk, CC2 review F3) — a call that arrives while /// is /// already false is a no-op rather than a second event. /// @@ -1777,13 +1829,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable /// Campaign CC slice CC3 review-fix round (F6): every branch below only /// SETS kind inside lock (_gate); the single /// call happens once, after the lock releases — - /// matching every other public method in this class. The Pending/Undef - /// branch previously published from inside the lock (harmless on its - /// own — 's own lock (_gate) is reentrant on - /// the same thread — but inconsistent with the rest of the class and a - /// lock-ordering risk once an observer callback reaches back into - /// caller-held locks, e.g. LiveSessionController._gate, while - /// still inside this one). + /// matching every other public method in this class. /// /// internal void ApplyCreationResponse(CharGenVerificationResponse.Parsed response) @@ -1803,15 +1849,29 @@ public sealed class RuntimeCharacterCreationState : IDisposable _lastRejection = null; kind = RuntimeCharacterCreationDeltaKind.Created; } - else if (response.AsCode is CharGenVerificationResponse.Code.Pending - or CharGenVerificationResponse.Code.Undef) - { - // Silent state reset — retail shows no dialog (ACE sends - // Pending for a disabled-Olthoi rejection; port as-is). - kind = RuntimeCharacterCreationDeltaKind.StateChanged; - } else { + // Review fix round F2 (2026-08-16): Pending/Undef used to + // short-circuit here as a silent state reset with no + // rejection produced — that was WRONG. Byte-decoded + // gmCharGenMainUI::RecvNotice_CharGenVerificationResponse + // @0x004e9030's own dispatch: Pending(2) is an explicit + // switch case landing on the SAME "ID_Character_Err_ + // NameDBDown" label as Corrupt/DatabaseDown, and Undef(0) + // (plus any code outside 1..7) falls through the function's + // own "(arg2-1) > 6" unsigned-underflow default arm to that + // identical label — retail's dispatch has NO silent branch + // at all; every non-Ok code shows a dialog. Falling through + // to this general rejection branch (instead of a special + // silent-reset arm) now produces a real + // RuntimeCharacterCreationRejection for Pending/Undef too, + // which CharacterCreationUiController.ReconcileDialogs maps + // to that same NameDBDown dialog (see its own doc comment). + // Concrete effect: ACE's disabled-Olthoi Pending rejection + // (CharacterHandler.CharacterCreateEx's olthoi_play_disabled + // branch) now surfaces a visible dialog instead of silently + // resetting verification state with Finish becoming a + // permanent no-op. string reason = response.AsCode.ToString(); _lastRejection = new RuntimeCharacterCreationRejection( response.RawCode, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index a12b8397..a27fd3c1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1055,6 +1055,64 @@ public sealed class CharacterCreationUiControllerTests Assert.False(field.CharacterFilter!('$')); } + // ── CC5 review fix round F12(d): RebuildListbox content ───────────── + + /// + /// F12(d) (2026-08-16): pins the F3 fix directly against the listbox's + /// actual built rows (not just the header/pair TEMPLATE ids the fixture + /// wires) — a skill row uses the key/value pair template (KEY = skill + /// name, VALUE = 's deterministic + /// stand-in), and BOTH bucket headers appear even though only ONE bucket + /// (Trained) has a matching skill — the case that actually exercises + /// "unconditional" (a header shown only because some row happened to + /// match would have passed even with the pre-fix lazy-header bug). + /// + [Fact] + public void Summary_RebuildListbox_SkillRows_UseKeyValueTemplate_WithUnconditionalHeaders() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + // SkillTrainOnly (1, "Axe") is Trained; SkillSpecializable (2, + // "Bow") is left Inactive entirely — no skill occupies the + // Specialized bucket. + environment.Runtime.View.SetSkillLevel(SkillTrainOnly, ChargenSkillAdvancementClass.Trained); + GoToSummary(environment); + + UiTemplateListBox listBox = environment.SummaryListBox(); + IReadOnlyList rows = Assert.IsType( + listBox.ViewportForTest).Children; + + // Ids match CharacterCreationSummaryPage's own private + // HeaderTextId/KeyTextId/ValueTextId constants — the same literals + // BuildSummaryHeaderTemplate/BuildSummaryPairTemplate below already + // hardcode for the fixture's row templates. + const uint headerTextId = 0x100000FEu; + const uint keyTextId = 0x100002FCu; + const uint valueTextId = 0x100002FDu; + + var headers = new List(); + var pairs = new List<(string Key, string Value)>(); + foreach (UiElement row in rows) + { + if (UiElement.FindDescendant(row, headerTextId) is UiText header) + headers.Add(JoinedText(header)); + else if (UiElement.FindDescendant(row, keyTextId) is UiText key + && UiElement.FindDescendant(row, valueTextId) is UiText value) + { + pairs.Add((JoinedText(key), JoinedText(value))); + } + } + + Assert.Contains("Specialized Skills", headers); + Assert.Contains("Trained Skills", headers); + Assert.Single(pairs, p => p.Key == "Axe" && p.Value == "10"); + Assert.DoesNotContain(pairs, p => p.Key == "Bow"); + } + + private static string JoinedText(UiText text) => + string.Join(" ", text.LinesProvider().Select(static line => line.Text)); + // ── CC5: 0xF643 rejection dialogs ───────────────────────────────────── [Fact] @@ -1082,6 +1140,60 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls); } + /// + /// F12(c) (CC5 review-fix round, 2026-08-16): a SECOND rejection with + /// IDENTICAL field values (same RawCode/Code/Reason/AttemptedName — + /// e.g. the player retried Finish with the SAME already-taken name) + /// must still show the dialog. ReconcileDialogs' own + /// _lastShownRejection dedup only suppresses re-showing a value + /// that is STILL the current LastRejection across ticks + /// (); + /// once nulls the + /// snapshot's rejection (mirroring the real + /// RuntimeCharacterCreationState.TryAcknowledgeRejection), + /// _lastShownRejection resets to null too, so the identical + /// value arriving a second time is treated as new. + /// + [Fact] + public void CreationFailed_IdenticalRejectionAfterAcknowledge_ReshowsTheDialog() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_Character_Err_NameReserved"] = "That name is in use."; + environment.Controller.Open(); + + var rejection = new RuntimeCharacterCreationRejection( + 3u, CharGenVerificationResponse.Code.NameInUse, "NameInUse", "Adventurer"); + + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection }; + BumpRevisionAndTick(environment); + Assert.True(environment.Dialogs.IsOpen); + + environment.DismissActiveMessageDialog(); + Assert.Equal(1, environment.Runtime.AcknowledgeRejectionCalls); + Assert.False(environment.Dialogs.IsOpen); + Assert.Null(environment.Runtime.View.Snapshot.LastRejection); + + // One intervening Tick with LastRejection == null — exactly what + // happens continuously in the real game loop between the + // acknowledge callback and the player's next Finish attempt — lets + // ReconcileDialogs' own `if (rejection is null) _lastShownRejection + // = null;` branch run BEFORE the identical value arrives again. + // Without this, _lastShownRejection still holds the acknowledged + // value and the dedup would (correctly, per its OWN contract) + // suppress a value that never actually went away in between. + environment.Controller.Tick(); + + // The identical rejection value arrives again. + environment.Runtime.View.Snapshot = environment.Runtime.View.Snapshot with { LastRejection = rejection }; + BumpRevisionAndTick(environment); + + Assert.True(environment.Dialogs.IsOpen); + Assert.Equal("That name is in use.", environment.LastDialogMessage()); + + environment.DismissActiveMessageDialog(); + Assert.Equal(2, environment.Runtime.AcknowledgeRejectionCalls); + } + [Fact] public void CreationFailed_SameRejectionAcrossTicks_ShowsOnlyOneDialog() { @@ -1188,6 +1300,9 @@ public sealed class CharacterCreationUiControllerTests public UiTemplateListBox SkillsList() => Assert.IsType(Screen.FindElement(0x100003F7u)); + public UiTemplateListBox SummaryListBox() => + Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId)); + public UiScrollbar ShadeScroll() => Assert.IsType(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId)); @@ -1278,9 +1393,19 @@ public sealed class CharacterCreationUiControllerTests RandomizeCharacter: RandomizeCharacter, RandomizeAppearance: () => { RandomizeAppearanceCalls++; return Result(RuntimeCommandStatus.Accepted); }, RandomizeClothing: () => { RandomizeClothingCalls++; return Result(RuntimeCommandStatus.Accepted); }, + GetSkillScore: GetSkillScore, OpenOnStart: false); } + /// F3/F12(d) (CC5 review-fix round, 2026-08-16): deterministic + /// stand-in for RetailSkillFormula.CalculateChargenScore — + /// skillId * 10 so tests can assert an exact, unambiguous value + /// without needing a real SkillTable. + private static uint GetSkillScore( + uint skillId, + ChargenAttributeValues attributes, + ChargenSkillAdvancementClass level) => skillId * 10u; + public FakeView View { get; } public CharacterCreationRuntimeBindings Bindings { get; } public bool ProvideView { get; set; } = true; diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs index d73d38b2..0b92a8c7 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -668,6 +668,18 @@ public sealed class RuntimeCharacterCreationStateTests [InlineData(CharGenVerificationResponse.Code.Corrupt)] [InlineData(CharGenVerificationResponse.Code.DatabaseDown)] [InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)] + // CC5 review-fix round F2 (2026-08-16): Pending/Undef used to be + // asserted as a SILENT reset producing no rejection at all + // (ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection, + // now deleted) — that assertion was wrong. Byte-decoded + // gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030: + // Pending is an explicit switch case landing on the SAME + // "ID_Character_Err_NameDBDown" label as Corrupt/DatabaseDown, and + // Undef falls through that function's own unsigned-underflow default + // arm to the identical label — retail's dispatch has no silent branch. + // Both now belong in this same "produces a rejection" theory. + [InlineData(CharGenVerificationResponse.Code.Pending)] + [InlineData(CharGenVerificationResponse.Code.Undef)] public void ApplyCreationResponse_EachRejectionCode_RecordsTheMappingAndAttemptedName( CharGenVerificationResponse.Code code) { @@ -685,24 +697,6 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Null(state.Snapshot.LastCreated); } - [Theory] - [InlineData(CharGenVerificationResponse.Code.Pending)] - [InlineData(CharGenVerificationResponse.Code.Undef)] - public void ApplyCreationResponse_PendingOrUndef_IsASilentResetWithNoRejection( - CharGenVerificationResponse.Code code) - { - // ACE sends Pending for a disabled-Olthoi rejection — retail shows - // no dialog. Port as-is. - RuntimeCharacterCreationState state = PendingState(out _); - - state.ApplyCreationResponse(new CharGenVerificationResponse.Parsed( - (uint)code, null, null, null)); - - Assert.False(state.Snapshot.VerificationPending); - Assert.Null(state.Snapshot.LastRejection); - Assert.Null(state.Snapshot.LastCreated); - } - [Fact] public void ApplyCreationResponse_DuplicateReplyWhileNotPending_IsIgnored() { @@ -791,6 +785,54 @@ public sealed class RuntimeCharacterCreationStateTests // RandomizeTemplate @ 0x005c6500's RandInt(count-1,...)+1 shape. Assert.NotEqual(0u, snapshot.Template); Assert.True(snapshot.StartArea is 0 or 1); + + // F8 (2026-08-16): every shade is RollShadeLocked's 32768-point + // lattice on [0.0, 1.0] INCLUSIVE (rand() in [0, 32767] * (1/32767)), + // never System.Random.NextDouble()'s continuous [0, 1). + Assert.InRange(snapshot.Appearance.SkinShade, 0.0, 1.0); + Assert.InRange(snapshot.Appearance.HairShade, 0.0, 1.0); + Assert.InRange(snapshot.Appearance.HeadgearShade, 0.0, 1.0); + Assert.InRange(snapshot.Appearance.ShirtShade, 0.0, 1.0); + Assert.InRange(snapshot.Appearance.TrousersShade, 0.0, 1.0); + Assert.InRange(snapshot.Appearance.FootwearShade, 0.0, 1.0); + } + + /// + /// F8 (2026-08-16): pins 's + /// private RollShadeLocked lattice (rand() in [0,32767] * + /// (1.0/32767.0)) deterministically via a fixed + /// double that always returns its maxValue - 1, confirming the + /// lattice's upper endpoint is EXACTLY reachable as 1.0 — a continuous + /// -style roll ([0, 1)) could never + /// produce that value. + /// + [Fact] + public void TryRandomizeCharacter_ShadeLattice_ReachesExactlyOneAtRandomMax() + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new MaxValueRandom()); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TryRandomizeCharacter()); + + RuntimeCharacterCreationAppearance a = state.Snapshot.Appearance; + Assert.Equal(1.0, a.SkinShade); + Assert.Equal(1.0, a.HairShade); + Assert.Equal(1.0, a.HeadgearShade); + Assert.Equal(1.0, a.ShirtShade); + Assert.Equal(1.0, a.TrousersShade); + Assert.Equal(1.0, a.FootwearShade); + } + + /// Always returns maxValue - 1 — the highest value + /// 's contract permits for any bound, so + /// every ordinary index pick in the randomize chain stays in-bounds + /// while the shade rolls (Next(32768)) land on 32767, the shade + /// lattice's top rung. + private sealed class MaxValueRandom : Random + { + public override int Next(int maxValue) => maxValue - 1; } /// @@ -894,4 +936,66 @@ public sealed class RuntimeCharacterCreationStateTests Assert.Equal(0u, state.Snapshot.Appearance.ShirtStyle); } + + /// + /// F12(a) (CC5 review-fix round, 2026-08-16): pins + /// RandomizeIndexExcludingLocked's exclude-current re-roll + /// property DETERMINISTICALLY. The fixture's HairStyles/ + /// HairColors/EyeColors lists are all COUNT 2 + /// ('s shared + /// gender record), so excluding the current index leaves exactly ONE + /// possible outcome — a second + /// call must flip every one of these three fields to the OTHER index, + /// regardless of which seed drives the roll. Two + /// different seeds both proving the flip is what makes this a property + /// pin rather than a single-seed coincidence. + /// + [Theory] + [InlineData(1)] + [InlineData(999)] + public void TryRandomizeAppearance_ExcludeCurrent_OnCountTwoLists_AlwaysFlips(int seed) + { + var state = new RuntimeCharacterCreationState( + RuntimeCharacterCreationStateFixture.Build(), + new Random(seed)); + state.Begin(new RuntimeGenerationToken(1)); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + + Assert.True(state.TryRandomizeAppearance()); + RuntimeCharacterCreationAppearance first = state.Snapshot.Appearance; + + Assert.True(state.TryRandomizeAppearance()); + RuntimeCharacterCreationAppearance second = state.Snapshot.Appearance; + + Assert.True(first.HairStyle is 0u or 1u); + Assert.True(first.HairColor is 0u or 1u); + Assert.True(first.EyeColor is 0u or 1u); + Assert.NotEqual(first.HairStyle, second.HairStyle); + Assert.NotEqual(first.HairColor, second.HairColor); + Assert.NotEqual(first.EyeColor, second.EyeColor); + } + + /// + /// F12(b) (CC5 review-fix round, 2026-08-16): pins + /// RandomizeCharacterLocked's own ClearSessionState + /// prologue (retail's own Reset() call) at the RUNTIME layer — + /// the Summary page's Random button must clear a committed name, which + /// is exactly the state transition CharacterCreationSummaryPage's + /// F1 fix (the field-sync _suppressNextFieldEvent removal) has to + /// coexist with correctly. + /// + [Fact] + public void TryRandomizeCharacter_ClearsAPreviouslyCommittedName() + { + RuntimeCharacterCreationState state = CreateActive(); + state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId); + state.TrySelectGender(RuntimeCharacterCreationStateFixture.MaleGenderKey); + Assert.True(state.TrySetName("Bob")); + Assert.Equal("Bob", state.Snapshot.Name); + + Assert.True(state.TryRandomizeCharacter()); + + Assert.Equal(string.Empty, state.Snapshot.Name); + } } From 2d4168f926f2a09501abf14a0b1a5dde4e6054d9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 01:14:10 +0200 Subject: [PATCH 102/138] =?UTF-8?q?docs:=20CC5=20ledger=20=E2=80=94=20reco?= =?UTF-8?q?rd=20the=20F1-F14=20fix-round=20commit=20sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the sha for 0c8e1e7d now that it exists; the previous commit could not self-reference its own hash. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-15-character-creation-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 2b5ff0e6..9ffb289b 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -266,7 +266,7 @@ the user gate. | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | -| CC5 | CODE-COMPLETE 2026-08-15 | `34e3a534`, `a975efd1` (ledger) + this fix-round commit | fix round landed F1-F14, narrow re-review pending | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | +| CC5 | CODE-COMPLETE 2026-08-15 | `34e3a534`, `a975efd1` (ledger), `0c8e1e7d` (fix round) | fix round landed F1-F14, narrow re-review pending | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | From 356545c530a1bad8790ab4f5d4f645821627b2e4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 01:57:07 +0200 Subject: [PATCH 103/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC5=20r?= =?UTF-8?q?e-review=20residuals=20R1-R5=20+=20nits=20=E2=80=94=20REVIEW-CL?= =?UTF-8?q?OSED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrow re-review of fix commits 0c8e1e7d+2d4168f9 found every code fix oracle-verified but returned NOT CLOSED on five test/doc residuals plus three nits and a follow-up filing. All fixed: - R1: the claimed "external-change->user-commit->SetName" regression test for F1 (the deleted _suppressNextFieldEvent latch) never existed — the Runtime-layer randomize test doesn't touch the page. Added CharacterCreationUiControllerTests.SummaryNameField_RealCommitAfter ExternalRefreshWhileUnfocused_StillReachesSetName: drives Refresh with a revision bump + changed snapshot.Name while the field is unfocused (the programmatic SetText path that used to arm the latch), THEN performs a real user commit (field.SetText + field.Submit(), the actual event path), asserting SetName receives the player's typed text. - R2: RetailSkillFormula.CalculateChargenScore and ChargenSkillScoreResolver had zero direct coverage (the F12(d) test substitutes skillId*10). Added a Untrained/Trained(+5)/Specialized(+10) theory, the divisor-zero skip path, and a six-way AttributeId theory (Str=1..Self=6) to RetailSkillFormulaTests.cs. - R3: RetailSkillFormula.cs's doc comment claimed "no retail-authored skill sets MinLevel above Untrained=1" without ever reading the field — ACE's own SkillBase.cs hedges the same field "// 1-2?". MEASURED (not assumed) against the installed EoR dat's global SkillTable (CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_ NeverExceedsTrained): 23 skills at MinLevel 1, 15 at MinLevel 2, zero above 2, of 38 priced skills. ACE's hedge was right; the doc comment now states the measured fact and leans on the structural argument (the gate holds for Trained/Specialized under any MinLevel in {1,2}) as load- bearing, not the unverified data claim. - R4: filed AP-228 — the Summary/Skills skill-row KEY sources from ItemAppraisalTextFormatter.SkillName's hardcoded English switch, where retail's own key is DAT-sourced (SkillBase->_name via %hs, 0x0047b90f-0x0047b915) — same divergence class as AP-226 filed the same round, reversed polarity, also present at CC4's Skills page. Softened AP-224's "ported exactly, not simplified" claim: it only ever covered the row's VALUE/template, never its KEY. - R5: this commit corrects 0c8e1e7d's gate claim. "Release build zero warnings" was false: a clean `dotnet build -c Release -t:Rebuild` shows 25 pre-existing warnings (18 in tests/AcDream.Core.Tests, 7 in tests/AcDream.App.Tests — Composition/HostInputCameraCompositionTests.cs, Composition/WorldRenderCompositionTests.cs, UI/Layout/OptionsPanelLiveMountProbeTests.cs), none in any file this campaign or its residual round touched. History is not amended; this is the correction. Nits: the ChargenPreviewController ctor doc now also cites gmCGSummaryPage::Update @0x0047baa0 (the per-heritage re-derive site — 0xc Olthoi/0xd OlthoiAcid/else — not just the one-shot InitializePage seed) as the stronger justification for why Rebuild re-derives the zoomed-out eye per heritage on every change. RuntimeCharacterCreationState's F2 comment ("Finish becoming a permanent no-op") reworded: the same unconditional _verificationPending = false assignment ran pre-fix too, so Finish was never blocked — only the response FEEDBACK vanished (no dialog, no created character, nothing), not the request itself. Filed #404 for ChargenSkillScoreResolver's own independent SkillTable read alongside ChargenTableReader's (cleanup follow-up, out of this round's scope). Ledger: CC5 flipped REVIEW-CLOSED in the campaign plan (dual-lens architectural PASS-with-items / retail-fidelity FAIL -> F1-F14 fix round 0c8e1e7d -> narrow re-review: all code oracle-verified, residuals R1-R5 test/doc -> this commit; re-reviewer pre-authorized lead diff-check close). This commit's own sha is recorded by a follow-up ledger-only commit, matching 2d4168f9's own pattern. Gates: Release build 0 errors (25 pre-existing warnings, unrelated to this round — see R5 above); App suite 5257/3 skips (was 5242/3), 0 failed; Runtime suite 1726/0 (unchanged); the three new/measured tests (R1, R2's ten cases, R3) all pass individually. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 39 +++++++ .../retail-divergence-register.md | 5 +- .../2026-08-15-character-creation-campaign.md | 2 +- src/AcDream.App/Net/RetailSkillFormula.cs | 23 +++- .../Rendering/ChargenPreviewController.cs | 17 ++- .../Session/RuntimeCharacterCreationState.cs | 13 ++- .../Net/RetailSkillFormulaTests.cs | 101 ++++++++++++++++++ .../Layout/CharacterCreationLiveDatTests.cs | 45 ++++++++ .../CharacterCreationUiControllerTests.cs | 45 ++++++++ 9 files changed, 277 insertions(+), 13 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 707258a9..767a3f9e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,45 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #404 — ChargenSkillScoreResolver duplicates ChargenTableReader's own SkillTable read + +**Status:** OPEN (post-CC cleanup follow-up) +**Severity:** LOW +**Filed:** 2026-08-16 (Campaign CC CC5 re-review residual round, nit 3) +**Component:** `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` +(`ChargenSkillScoreResolver` construction, `:670-672`), +`src/AcDream.Content/CharGen/ChargenTableReader.cs` (`:41`, `:61`) + +`ChargenSkillScoreResolver`'s constructor takes its OWN independent read of +the global SkillTable (portal.dat `0x0E000004`) at composition time +(`InteractionRetainedUiComposition.cs:670-672`, +`d.Dats.Get(0x0E000004u)`), beside `ChargenTableReader`'s +own already-established read of the SAME table +(`ChargenTableReader.cs:41` names the id, `:61` reads it) — which discards +the DAT's `SkillFormula` field entirely (`ChargenTableReader.Project` only +projects `TrainedCost`/`SpecializedCost` per skill into +`ChargenSkillCost`, never `SkillBase.Formula`). Two independent reads of +the same DAT file are harmless today (both are read-only, one-shot, under +the DAT lock) but are a duplicate-source-of-truth smell: if the two readers +ever diverge (a caching change, a future write path), nothing enforces they +stay in sync. + +**Fix direction:** project `SkillFormula` (and `MinLevel`, needed by +`RetailSkillFormula.CalculateChargenScore`'s gate) into `ChargenOptions` +alongside the existing `GlobalSkillCostsBySkillId` — `ChargenTableReader` +already walks every `SkillBase` in the table +(`ChargenTableReader.Project`'s `globalSkillCosts` loop) so adding the +formula/MinLevel costs no new DAT read, just a wider projection type. Then +`ChargenSkillScoreResolver` becomes pure arithmetic over `ChargenOptions` +it already receives from the caller, with no `SkillTable`/DAT dependency of +its own, and its constructor-time DAT read goes away entirely. + +**Acceptance:** one SkillTable read at composition time (through +`ChargenTableReader`), not two; `ChargenSkillScoreResolver` (or its +replacement) takes `ChargenOptions`/a projected formula table instead of a +raw `SkillTable`; existing `RetailSkillFormulaTests`/`ChargenTableReaderInstalledDatTests` +coverage still passes. + ## #403 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch **Status:** OPEN (post-CC consolidation follow-up) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b20384ff..142690a7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 161 active rows (AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 162 active rows (AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -207,9 +207,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-228 | **Filed 2026-08-16 at the CC5 re-review residual round (R4).** The Summary listbox's skill-row KEY (the skill's display name) sources from `ItemAppraisalTextFormatter.SkillName(int)` — a hardcoded English `switch` over the 54 skill ids — where retail's own `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` builds that same key from the DAT-sourced `SkillBase->_name` field via a `%hs` format substitution (`data_79f3f0`, `0x0047b90f`-`0x0047b915`). Same divergence CLASS as AP-226 (a hardcoded acdream string standing in for a DAT-sourced retail field) but the polarity is REVERSED: AP-226 is retail-static-vs-acdream-DAT-sourced, while here retail is the DAT-sourced side and acdream is the hardcoded side. The identical pattern is ALSO present at a second call site, CC4's Skills page (`CharacterCreationSkillsPage`), which builds its own row labels through the SAME `ItemAppraisalTextFormatter.SkillName` call — not a second, independent divergence, the same one surfacing twice. | `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs` (`SkillName`), consumed by `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`AddSkillBucket`) and `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` | `SkillName` already backs every OTHER retail skill-name surface acdream has shipped (item-appraisal skill lines, wield-requirement text, usage-limit text — `ItemAppraisalTextFormatter`'s whole existing surface) — the Summary/Skills chargen pages reusing it keeps one skill-name source across the client instead of introducing a second, DAT-reading one for chargen alone. English-only is consistent with the rest of the client's current localization posture (no other surface reads a localized skill name from the DAT either). | A non-English or modded DAT install would show its real, localized skill names on retail's character sheet and item-examine windows but acdream's chargen Summary/Skills pages would keep showing the hardcoded English name regardless — a localization-only divergence, never a wire or gameplay difference (the skill id sent over the wire is unaffected). | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` (`data_79f3f0`, `%hs` substitution `0x0047b90f`-`0x0047b915`) | | AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | | AP-226 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 (the Summary listbox's Profession/Gender/Heritage/Starting Town label sources).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` sources these four labels from four STATIC wide-string tables baked into the binary's data section: `pcProfessions[0x7] @ 0x008191a8` ("Custom", "Bow Hunter", "Swashbuckler", "Life Caster", "War Mage", "Wayfarer", "Soldier"), `pcGender[0x3] @ 0x008191c4` ("?", "Male", "Female"), `pcHeritage[0x5] @ 0x008191d0` ("?", "Aluvian", "Gharu'ndim", "Sho", "Viamontian"), `pcTown[0x4] @ 0x008191e4` ("Holtburg", "Shoushi", "Yaraq", "Sanamar") — each indexed directly by the character's `template_`/`mGender`/`mHeritageGroup`/`startArea` field, each guarded by an upper-bound-only range check (`template_ <= 6`, `mGender <= 2`, `mHeritageGroup <= 4`, `startArea <= 3`) with NO append at all when the index is out of range. Concretely: **`pcHeritage`'s guard is `mHeritageGroup <= 4` — heritage ids 5 and above (every NON-HUMAN heritage: Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, Olthoi, OlthoiAcid) are never appended, so retail's own Summary page renders a BARE `"Heritage: "` with no name at all for a non-human character** — a genuine retail quirk, not a decompiler artifact (confirmed by the same guard shape on all four tables). `CharacterCreationSummaryPage`'s port instead sources every label from the already-loaded `ChargenOptions` DAT model (`heritage.Templates[i].Name`, `gender.Name`, `heritage.Name`, `options.StarterAreas[i].Name`) and prints the literal `"None"` when the index is unresolved, for EVERY heritage including non-human ones — never a bare label. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`ProfessionName`, `GenderName`, `RebuildListbox`'s `"Heritage: " + heritage.Name`, `StarterAreaName`) | The DAT-sourced names are the SAME strings a player already sees on every earlier chargen page (Heritage/Profession/Town pages all source from the identical `ChargenOptions` model) — reusing them keeps the Summary page internally consistent with the rest of the screen rather than introducing a second, static, English-only label source that could drift from the DAT (localization, a modded heritage table) or blank out for heritages retail's own hardcoded table never anticipated. | A pixel-level side-by-side against retail would show a non-human character's Summary "Heritage:" row completely empty of a name in retail (an accepted retail bug/limitation) versus acdream always showing the real heritage name — a cosmetic improvement, never a correctness or wire-format difference; a non-English/modded DAT install could theoretically show acdream a label retail's hardcoded English table never had, which is again strictly more informative, not less. | `pcProfessions[0x7] @0x008191a8`; `pcGender[0x3] @0x008191c4`; `pcHeritage[0x5] @0x008191d0`; `pcTown[0x4] @0x008191e4`; `gmCGSummaryPage::SetSummaryText @0x0047b1d0` (the four guard+append sites) | -| AP-224 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary listbox content).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` walks FOUR skill buckets (Specialized, Trained, UseableUntrained, UnuseableUntrained) and lists every skill name in each, via a nested loop over `skillRecordList`. `CharacterCreationSummaryPage.AddSkillBucket` lists Specialized and Trained only, skipping the two Untrained buckets — mirroring AP-213's own already-accepted Skills-page simplification precedent (same class of cut: presentation grouping, not correctness). Health/Stamina/Mana values reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas (Health=Endurance/2, Stamina=Endurance, Mana=Self) rather than this page's OWN `SetSummaryText` call site, whose two `GetAttribute` calls for Health/Stamina both show a literal attribute index of `2` in the decompiled pseudo-C — a decompiler-ambiguous pair the cleaner Profession-page citation sidesteps rather than reproduces uncritically. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`RebuildListbox`, `AddSkillBucket`) | The two Untrained buckets would list the ~40+ skills the player did NOT touch — volume without decision-relevant information for a pre-Finish review screen; every skill's actual cost/level data remains identical and inspectable on the Skills page itself. The Health/Stamina/Mana citation choice favors a decomp site with an unambiguous formula over one with a decompiler artifact. | A player scanning Summary for "what am I NOT trained in" has to go back to the Skills page instead of seeing it listed here — a discoverability gap, not a correctness gap; the row TEMPLATE mechanism itself (three retail row types: single-line, header, key/value pair) is ported exactly, live-DAT-probe-confirmed, not simplified. **Correction, CC5 review-fix round F3 (2026-08-16): this last claim was FALSE as originally shipped — the skill rows this row's own `AddSkillBucket` builds used template 0 (single line, name only) instead of template 2 (key/value pair, `CharGenState::GetSkillScore @ 0x005C4B50` as the value) and its bucket headers were added lazily (only when the bucket had a match) instead of retail's own unconditional add. Both are fixed this round (`RetailSkillFormula.CalculateChargenScore`, wired via the new `CharacterCreationRuntimeBindings.GetSkillScore` binding) — the "ported exactly, not simplified" claim is true again, but it was not verified against the ACTUAL row template/value at CC5 ship time, only against the listbox's INDEX/TYPE shape. A SEPARATE, more severe bug surfaced writing this round's own regression test (F12(d)): `CharacterCreationSummaryPage`'s constructor never assigned `_list.TemplateResolver` at all (every sibling `UiTemplateListBox` owner — `CharacterCreationSkillsPage`, `CharacterManagementUiController`, every Options-panel controller — does this in its own constructor; this page never did), so `ResolveTemplateRow`'s own null-resolver guard made EVERY `RebuildListbox` call a silent no-op — the Summary listbox rendered NO rows at all (not just wrong-template skill rows) from CC5's ship date until this fix. Also fixed this round (`CharacterCreationSummaryPage`'s new `templateResolver` constructor parameter).** | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0`; `CharacterCreationProfessionPage.Refresh`'s own `UpdateAttributeValues @ 0x00482450` citation; `CharGenState::GetSkillScore @ 0x005C4B50`; `SkillFormula::Calculate @ 0x00591960` | +| AP-224 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary listbox content).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` walks FOUR skill buckets (Specialized, Trained, UseableUntrained, UnuseableUntrained) and lists every skill name in each, via a nested loop over `skillRecordList`. `CharacterCreationSummaryPage.AddSkillBucket` lists Specialized and Trained only, skipping the two Untrained buckets — mirroring AP-213's own already-accepted Skills-page simplification precedent (same class of cut: presentation grouping, not correctness). Health/Stamina/Mana values reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas (Health=Endurance/2, Stamina=Endurance, Mana=Self) rather than this page's OWN `SetSummaryText` call site, whose two `GetAttribute` calls for Health/Stamina both show a literal attribute index of `2` in the decompiled pseudo-C — a decompiler-ambiguous pair the cleaner Profession-page citation sidesteps rather than reproduces uncritically. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`RebuildListbox`, `AddSkillBucket`) | The two Untrained buckets would list the ~40+ skills the player did NOT touch — volume without decision-relevant information for a pre-Finish review screen; every skill's actual cost/level data remains identical and inspectable on the Skills page itself. The Health/Stamina/Mana citation choice favors a decomp site with an unambiguous formula over one with a decompiler artifact. | A player scanning Summary for "what am I NOT trained in" has to go back to the Skills page instead of seeing it listed here — a discoverability gap, not a correctness gap; the row TEMPLATE mechanism itself (three retail row types: single-line, header, key/value pair) is ported exactly, live-DAT-probe-confirmed, not simplified. **Correction, CC5 review-fix round F3 (2026-08-16): this last claim was FALSE as originally shipped — the skill rows this row's own `AddSkillBucket` builds used template 0 (single line, name only) instead of template 2 (key/value pair, `CharGenState::GetSkillScore @ 0x005C4B50` as the value) and its bucket headers were added lazily (only when the bucket had a match) instead of retail's own unconditional add. Both are fixed this round (`RetailSkillFormula.CalculateChargenScore`, wired via the new `CharacterCreationRuntimeBindings.GetSkillScore` binding) — the "ported exactly, not simplified" claim is true again, but it was not verified against the ACTUAL row template/value at CC5 ship time, only against the listbox's INDEX/TYPE shape — and even now that claim covers the row's VALUE and TEMPLATE shape only. **Further correction, CC5 re-review residual round R4 (2026-08-16): the row's KEY (the skill name) was never covered by the "ported exactly" claim at all — it is a separate, pre-existing divergence (AP-228) this fix neither introduced nor closed.** A SEPARATE, more severe bug surfaced writing this round's own regression test (F12(d)): `CharacterCreationSummaryPage`'s constructor never assigned `_list.TemplateResolver` at all (every sibling `UiTemplateListBox` owner — `CharacterCreationSkillsPage`, `CharacterManagementUiController`, every Options-panel controller — does this in its own constructor; this page never did), so `ResolveTemplateRow`'s own null-resolver guard made EVERY `RebuildListbox` call a silent no-op — the Summary listbox rendered NO rows at all (not just wrong-template skill rows) from CC5's ship date until this fix. Also fixed this round (`CharacterCreationSummaryPage`'s new `templateResolver` constructor parameter).** | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0`; `CharacterCreationProfessionPage.Refresh`'s own `UpdateAttributeValues @ 0x00482450` citation; `CharGenState::GetSkillScore @ 0x005C4B50`; `SkillFormula::Calculate @ 0x00591960` | | AP-223 | **Filed 2026-08-15 at Campaign CC slice CC5 (the F12 amendment's own explicit ask — see AP-214's now-retired "Latent Finish-path interaction" note).** `RuntimeCharacterCreationState.TryBeginFinish` gains a NEW local refusal, `HeritageOrGenderUnset`, checked right after the empty-name check. Retail's own `gmCharGenMainUI::DoFinish @ 0x004E9170` has NO such check in the decompiled code — but it doesn't need one: `RandomizeCharacter` at ctor time (now ported, see AD-101/AP-212/AP-214's history) guarantees heritage+gender are ALWAYS real by the time any page — including Summary/Finish — exists. This refusal is acdream's OWN defensive backstop for a caller that reaches `Finish` without that screen-open roll ever having run (a headless bot driving `RuntimeCharacterCreationState` directly, or a future caller that bypasses `CharacterCreationUiController.Open`). Under the ordinary UI it is normally unreachable (the roll always fires first). | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`, `TryBeginFinish`) | Retail's own guarantee is architectural (a roll that always runs before any page exists), not a runtime check — acdream's UI reproduces the roll (`CharacterCreationUiController.Open` → `RollOpeningCharacter`) but a direct Runtime caller could still skip it, so a local refusal is the honest choice over silently sending a heritage-0/gender-0 wire request ACE would likely reject anyway for unrelated reasons. | A caller that bypasses the normal screen-open path and calls `Finish` before ever selecting heritage/gender gets a local refusal instead of a wire round-trip to discover the same failure — no server-visible consequence either way. | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`, the ctor-time roll); `CharGenState::RandomizeCharacter @0x005c6d80`; `gmCharGenMainUI::DoFinish @ 0x004E9170` (no heritage/gender check present) | | AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 9ffb289b..529b5e3d 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -266,7 +266,7 @@ the user gate. | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | -| CC5 | CODE-COMPLETE 2026-08-15 | `34e3a534`, `a975efd1` (ledger), `0c8e1e7d` (fix round) | fix round landed F1-F14, narrow re-review pending | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). | +| CC5 | REVIEW-CLOSED 2026-08-16 | `34e3a534`, `a975efd1` (ledger), `0c8e1e7d` (fix round), `2d4168f9` (ledger), residual round (this commit — sha recorded by a follow-up ledger-only commit, matching `2d4168f9`'s own pattern since a commit cannot self-reference its own sha) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F14 fix round `0c8e1e7d` → narrow re-review: all code fixes oracle-verified, residuals R1-R5 all test/doc → this commit; re-reviewer pre-authorized lead diff-check close) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). **Re-review residual round (2026-08-16, this commit):** the narrow re-review of `0c8e1e7d` found every code fix oracle-verified but returned NOT CLOSED on five test/doc residuals plus nits. R1 — added the missing App-layer regression test (`CharacterCreationUiControllerTests.SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName`) that actually drives the F1 bug shape (external Refresh-driven `SetText` while unfocused, THEN a real `SetText`+`Submit` user commit), since the claimed coverage never touched the page. R2 — added direct `RetailSkillFormula.CalculateChargenScore`/`ChargenSkillScoreResolver` coverage (`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`: a Untrained/Trained/Specialized theory, the divisor-zero skip path, and a six-way `AttributeId` theory), replacing the F12(d) test's `skillId * 10` substitute as the ONLY prior coverage. R3 — MEASURED (not assumed) the installed DAT's SkillTable `MinLevel` distribution (`CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_NeverExceedsTrained`: 23 skills at MinLevel 1, 15 at MinLevel 2, of 38 priced skills, zero above 2) and restated `RetailSkillFormula.cs`'s doc comment around the measured fact instead of the unverified "no skill exceeds Untrained=1" claim — ACE's own hedge (`// 1-2?`) was right; the structural "gate holds for any MinLevel in {1,2}" argument is now the load-bearing one, not the data claim. R4 — filed AP-228 (the Summary/Skills skill-row KEY sourcing from `ItemAppraisalTextFormatter.SkillName`'s hardcoded English switch, where retail's own key is DAT-sourced — same class as AP-226, reversed polarity, also present at CC4's Skills page) and softened AP-224's "ported exactly" claim to note it only ever covered the row's VALUE/template, never its KEY. R5 — this commit's message corrects `0c8e1e7d`'s false "Release build zero warnings" gate claim (18 pre-existing warnings, all in the unrelated `AcDream.Core.Tests` project, none in any project this campaign touched). Plus three nits: the `ChargenPreviewController` ctor doc now also cites `gmCGSummaryPage::Update @0x0047baa0` (the per-heritage re-derive site, not just the one-shot `InitializePage` seed); the F2 inline comment's "Finish becoming a permanent no-op" reworded (`_verificationPending` was already cleared pre-fix too — Finish was never blocked, only the RESPONSE feedback vanished); and #404 filed for `ChargenSkillScoreResolver`'s own independent SkillTable read alongside `ChargenTableReader`'s (cleanup, not urgent — not this round's scope). | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index 8ae59ba3..f1238af6 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -48,11 +48,24 @@ internal static class RetailSkillFormula /// typed SkillBase.MinLevel field is the same value cleanly), is /// satisfied for both callers of this method (Specialized=3 and /// Trained=2 are the only two advancement classes CC5's Summary listbox - /// still shows — AP-224 — and no retail-authored skill sets - /// MinLevel above Untrained=1) so it is not reproduced as a - /// separate branch; a future caller passing - /// or would need - /// that gate ported for real. + /// still shows — AP-224) FOR ANY MinLevel in {1, 2} — that + /// structural argument, not an assumption about the data, is what makes + /// omitting the branch safe. CC5 re-review residual round, R3 + /// (2026-08-16), corrects the data claim this comment used to make in + /// place of that argument ("no retail-authored skill sets MinLevel + /// above Untrained=1"): ACE's own SkillBase.cs annotates the + /// identical field // 1-2?, a hedge this port never checked. + /// MEASURED against the installed EoR dat's global SkillTable + /// (): + /// of the 38 priced skills, 23 carry MinLevel == 1 and 15 carry + /// MinLevel == 2 — ACE's hedge was correct (real 2s exist), and + /// no skill in the installed table exceeds 2, so the gate stays + /// satisfied — but the ORIGINAL claim ("no skill sets MinLevel above + /// Untrained=1") was false as written, unrelated to whether omitting + /// the branch happens to still be safe. A future caller passing + /// or + /// would need that + /// gate ported for real regardless of MinLevel's observed range. /// public static uint CalculateChargenScore( SkillBase skillBase, diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index edab1fc5..4727cecb 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -204,9 +204,20 @@ internal sealed class ChargenPreviewController : /// eye literal (0, -2.5, 0.95) at ~0x0047bd14-0x0047bd44 — /// exactly 's /// default-heritage value, NOT the zoomed-in one this controller used - /// before the fix). Summary has no zoom buttons at all (retail's own - /// viewport there is fixed-framing), so this is a permanent camera - /// profile for the controller's whole lifetime, not a toggle. + /// before the fix). CC5 re-review residual round, nit 1 (2026-08-16): + /// InitializePage alone only justifies the ONE-TIME seed below — + /// the STRONGER citation for why re-derives this + /// same eye PER HERITAGE on every heritage/gender change (not just + /// once) is gmCGSummaryPage::Update @ 0x0047baa0, which re-sets + /// the camera on every update using the identical per-heritage mapping + /// already + /// implements (0xc Olthoi → (0, -3.8, 1.15), 0xd + /// OlthoiAcid → (0, -5.7, 1.65), else → (0, -2.5, 0.95)) — + /// confirming the per-heritage re-derive below is retail-correct, not + /// an acdream-only elaboration on a one-shot init value. Summary has no + /// zoom buttons at all (retail's own viewport there is fixed-framing), + /// so this is a permanent camera profile for the controller's whole + /// lifetime, not a toggle. public ChargenPreviewController( IChargenPreviewRenderer renderer, ChargenPreviewCamera camera, diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index 1bf44ce6..e305d02e 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -1870,8 +1870,17 @@ public sealed class RuntimeCharacterCreationState : IDisposable // Concrete effect: ACE's disabled-Olthoi Pending rejection // (CharacterHandler.CharacterCreateEx's olthoi_play_disabled // branch) now surfaces a visible dialog instead of silently - // resetting verification state with Finish becoming a - // permanent no-op. + // resetting verification state. CC5 re-review residual + // round, nit 2 (2026-08-16): "Finish becoming a permanent + // no-op" overstated the old symptom — this same + // unconditional `_verificationPending = false` assignment + // above ALSO ran pre-fix, so Finish was never blocked and + // each click still sent a fresh CharacterCreate request. + // What actually vanished was the FEEDBACK: every response + // produced no player-visible outcome at all (no dialog, no + // created character, nothing), so repeated Finish clicks + // looked identical to doing nothing, even though each one + // was a real round trip to ACE. string reason = response.AsCode.ToString(); _lastRejection = new RuntimeCharacterCreationRejection( response.RawCode, diff --git a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs index 50db95f1..8b4dc7ab 100644 --- a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs +++ b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs @@ -1,4 +1,5 @@ using AcDream.App.Net; +using AcDream.Core.CharGen; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -109,4 +110,104 @@ public sealed class RetailSkillFormulaTests Attribute2Multiplier = y, Divisor = unchecked((int)z), }; + + // ── CC5 re-review residual round, R2 (2026-08-16): RetailSkillFormula. + // ── CalculateChargenScore / ChargenSkillScoreResolver had zero direct + // ── coverage — the F12(d) test substitutes `skillId * 10` instead of + // ── exercising the real formula. ───────────────────────────────────── + + /// + /// 's level bonus: + /// Untrained adds nothing, Trained adds 5, Specialized adds 10, on top + /// of the SAME base result + /// (formula here: w=0, x=1, y=0, z=1 against + /// attribute1=5 -> base 5). + /// + [Theory] + [InlineData(ChargenSkillAdvancementClass.Untrained, 5u)] + [InlineData(ChargenSkillAdvancementClass.Trained, 10u)] + [InlineData(ChargenSkillAdvancementClass.Specialized, 15u)] + public void CalculateChargenScore_AddsTheLevelBonusOnTopOfTheBaseFormula( + ChargenSkillAdvancementClass level, + uint expected) + { + var skillBase = new SkillBase { Formula = Formula(w: 0, x: 1, y: 0, z: 1) }; + + uint result = RetailSkillFormula.CalculateChargenScore(skillBase, attribute1: 5u, attribute2: 0u, level); + + Assert.Equal(expected, result); + } + + /// + /// The divisor-zero skip path: 's + /// own failure gate short-circuits + /// to a flat 0 BEFORE the level bonus is ever added — even for + /// Specialized, which would otherwise add 10. + /// + [Fact] + public void CalculateChargenScore_ZeroDivisor_ReturnsZero_RegardlessOfLevel() + { + var skillBase = new SkillBase { Formula = Formula(w: 7, x: 1, y: 1, z: 0) }; + + Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore( + skillBase, 10u, 20u, ChargenSkillAdvancementClass.Untrained)); + Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore( + skillBase, 10u, 20u, ChargenSkillAdvancementClass.Trained)); + Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore( + skillBase, 10u, 20u, ChargenSkillAdvancementClass.Specialized)); + } + + /// + /// 's six-way + /// ResolveAttribute switch — one case per + /// (Strength=1..Self=6) — resolving through a + /// formula that reads ONLY Attribute1 (x=1, y=0) so the + /// result unambiguously reports which of the six + /// fields the switch actually read. + /// + [Theory] + [InlineData(AttributeId.Strength)] + [InlineData(AttributeId.Endurance)] + [InlineData(AttributeId.Coordination)] + [InlineData(AttributeId.Quickness)] + [InlineData(AttributeId.Focus)] + [InlineData(AttributeId.Self)] + public void ChargenSkillScoreResolver_ResolvesEachAttributeIdThroughTheSixWaySwitch( + AttributeId attributeId) + { + const uint skillId = 0x10u; + var skillTable = new SkillTable(); + skillTable.Skills.Add((SkillId)skillId, new SkillBase + { + Formula = new SkillFormula + { + AdditiveBonus = 0, + Attribute1Multiplier = 1, + Attribute2Multiplier = 0, + Divisor = 1, + Attribute1 = attributeId, + // Attribute2 deliberately left at its zero default (Strength) — + // Attribute2Multiplier=0 means whatever it reads contributes + // nothing, so it cannot mask a wrong Attribute1 case. + }, + }); + var resolver = new ChargenSkillScoreResolver(skillTable); + ChargenAttributeValues attributes = AttributeValuesWith(attributeId, 42); + + uint result = resolver.Resolve(skillId, attributes, ChargenSkillAdvancementClass.Untrained); + + Assert.Equal(42u, result); + } + + private static ChargenAttributeValues AttributeValuesWith(AttributeId attributeId, int value) => + attributeId switch + { + AttributeId.Strength => new ChargenAttributeValues(value, 0, 0, 0, 0, 0), + AttributeId.Endurance => new ChargenAttributeValues(0, value, 0, 0, 0, 0), + AttributeId.Coordination => new ChargenAttributeValues(0, 0, value, 0, 0, 0), + AttributeId.Quickness => new ChargenAttributeValues(0, 0, 0, value, 0, 0), + AttributeId.Focus => new ChargenAttributeValues(0, 0, 0, 0, value, 0), + AttributeId.Self => new ChargenAttributeValues(0, 0, 0, 0, 0, value), + _ => default, + }; } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index d159e27d..9d450c08 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -572,6 +572,51 @@ public sealed class CharacterCreationLiveDatTests Assert.IsType(UiElement.FindDescendant(pairRow, 0x100002FDu)); } + /// + /// CC5 re-review residual round, R3 (2026-08-16): MEASURES the + /// installed global SkillTable's (portal.dat 0x0E000004) + /// MinLevel distribution instead of inferring it — the C4 + /// closeout's "observe, don't infer" lesson + /// (docs/research/2026-08-05-c4-closeout-handoff.md). + /// 's + /// own doc comment used to claim "no retail-authored skill sets + /// MinLevel above Untrained=1" without ever reading the field; ACE's own + /// SkillBase.cs annotates the same field // 1-2? (a hedge + /// that observed 2s exist). This is that measurement — see the method's + /// own real-DAT finding recorded in RetailSkillFormula.cs's doc + /// comment, corrected from this test's result. + /// + [InstalledDatFact] + public void SkillTable_MinLevelDistribution_NeverExceedsTrained() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + DatReaderWriter.DBObjs.SkillTable? skillTable = + dats.Get(0x0E000004u); + Assert.NotNull(skillTable); + + var byMinLevel = skillTable!.Skills.Values + .GroupBy(skill => skill.MinLevel) + .OrderBy(g => g.Key) + .ToDictionary(g => g.Key, g => g.Count()); + Console.WriteLine( + "[CC5-R3-DAT] SkillTable MinLevel distribution (level=count): " + + string.Join(", ", byMinLevel.Select(p => $"{p.Key}={p.Value}")) + + $" (total skills: {skillTable.Skills.Count})"); + + // RetailSkillFormula.CalculateChargenScore's own gate argument ("the + // decomp's own `if (edi_1 >= MinLevel)` gate passes for both callers, + // Trained=2 and Specialized=3") only holds while every skill's + // MinLevel stays at or below Trained(2) — pin that as a real + // assertion instead of leaving it as prose, so a future DAT + // revision that adds a MinLevel=3+ skill fails HERE, not silently + // under-credits that skill's chargen score. + Assert.True( + byMinLevel.Keys.All(minLevel => minLevel <= 2), + "A skill's MinLevel exceeded 2 (Trained) in the installed DAT — " + + "RetailSkillFormula.CalculateChargenScore's Trained/Specialized " + + "gate argument needs re-verification for this skill."); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index a27fd3c1..4dc9b0e6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1055,6 +1055,51 @@ public sealed class CharacterCreationUiControllerTests Assert.False(field.CharacterFilter!('$')); } + /// + /// CC5 re-review residual round, R1 (2026-08-16): pins the F1 fix (the + /// deleted _suppressNextFieldEvent latch) against reintroduction. + /// The pre-fix bug needed BOTH halves to reproduce: (1) an EXTERNAL + /// change to snapshot.Name lands while the field is unfocused — + /// CharacterCreationSummaryPage.Refresh's own field-sync block + /// (the F1 fix site) calls field.SetText(...) programmatically, + /// which armed the old latch — then (2) the PLAYER's own REAL commit + /// (SetText + Submit, the actual event path a keystroke + + /// Enter/blur drives — never a direct CommitNameFromField call) + /// arrives afterward. Pre-fix, that real commit hit the still-armed + /// latch and was silently dropped; + /// alone never exercised this because it never drives step (1) first — + /// the review's own finding was that the claimed regression coverage + /// didn't actually touch the page. + /// + [Fact] + public void SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + GoToSummary(environment); + UiField field = environment.SummaryNameField(); + + // (1) External change while unfocused: something OTHER than this + // field bumps the Runtime revision with a changed Name (e.g. the + // player edited another page and came back) — Refresh's field-sync + // block programmatically overwrites the field via SetText, which + // never raises OnFocusLost/OnSubmit. + RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with + { + Revision = snapshot.Revision + 1, + Name = "Zorak", + }; + environment.Controller.Tick(); + Assert.Equal("Zorak", field.Text); + + // (2) The player's own real commit afterward. + field.SetText("Adventurer"); + field.Submit(); + + Assert.Equal("Adventurer", environment.Runtime.LastSetName); + } + // ── CC5 review fix round F12(d): RebuildListbox content ───────────── /// From bb22ee8bdef0e9d4832f4463800aeeea1f21507f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 01:57:21 +0200 Subject: [PATCH 104/138] =?UTF-8?q?docs:=20CC5=20ledger=20=E2=80=94=20reco?= =?UTF-8?q?rd=20the=20re-review=20residual=20round's=20commit=20sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in 356545c5 now that it exists, matching a975efd1/2d4168f9's own pattern of a follow-up docs-only commit for a fix round that cannot self-reference its own sha. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-15-character-creation-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 529b5e3d..9a42c17f 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -266,7 +266,7 @@ the user gate. | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | -| CC5 | REVIEW-CLOSED 2026-08-16 | `34e3a534`, `a975efd1` (ledger), `0c8e1e7d` (fix round), `2d4168f9` (ledger), residual round (this commit — sha recorded by a follow-up ledger-only commit, matching `2d4168f9`'s own pattern since a commit cannot self-reference its own sha) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F14 fix round `0c8e1e7d` → narrow re-review: all code fixes oracle-verified, residuals R1-R5 all test/doc → this commit; re-reviewer pre-authorized lead diff-check close) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). **Re-review residual round (2026-08-16, this commit):** the narrow re-review of `0c8e1e7d` found every code fix oracle-verified but returned NOT CLOSED on five test/doc residuals plus nits. R1 — added the missing App-layer regression test (`CharacterCreationUiControllerTests.SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName`) that actually drives the F1 bug shape (external Refresh-driven `SetText` while unfocused, THEN a real `SetText`+`Submit` user commit), since the claimed coverage never touched the page. R2 — added direct `RetailSkillFormula.CalculateChargenScore`/`ChargenSkillScoreResolver` coverage (`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`: a Untrained/Trained/Specialized theory, the divisor-zero skip path, and a six-way `AttributeId` theory), replacing the F12(d) test's `skillId * 10` substitute as the ONLY prior coverage. R3 — MEASURED (not assumed) the installed DAT's SkillTable `MinLevel` distribution (`CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_NeverExceedsTrained`: 23 skills at MinLevel 1, 15 at MinLevel 2, of 38 priced skills, zero above 2) and restated `RetailSkillFormula.cs`'s doc comment around the measured fact instead of the unverified "no skill exceeds Untrained=1" claim — ACE's own hedge (`// 1-2?`) was right; the structural "gate holds for any MinLevel in {1,2}" argument is now the load-bearing one, not the data claim. R4 — filed AP-228 (the Summary/Skills skill-row KEY sourcing from `ItemAppraisalTextFormatter.SkillName`'s hardcoded English switch, where retail's own key is DAT-sourced — same class as AP-226, reversed polarity, also present at CC4's Skills page) and softened AP-224's "ported exactly" claim to note it only ever covered the row's VALUE/template, never its KEY. R5 — this commit's message corrects `0c8e1e7d`'s false "Release build zero warnings" gate claim (18 pre-existing warnings, all in the unrelated `AcDream.Core.Tests` project, none in any project this campaign touched). Plus three nits: the `ChargenPreviewController` ctor doc now also cites `gmCGSummaryPage::Update @0x0047baa0` (the per-heritage re-derive site, not just the one-shot `InitializePage` seed); the F2 inline comment's "Finish becoming a permanent no-op" reworded (`_verificationPending` was already cleared pre-fix too — Finish was never blocked, only the RESPONSE feedback vanished); and #404 filed for `ChargenSkillScoreResolver`'s own independent SkillTable read alongside `ChargenTableReader`'s (cleanup, not urgent — not this round's scope). | +| CC5 | REVIEW-CLOSED 2026-08-16 | `34e3a534`, `a975efd1` (ledger), `0c8e1e7d` (fix round), `2d4168f9` (ledger), residual round `356545c5` | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F14 fix round `0c8e1e7d` → narrow re-review: all code fixes oracle-verified, residuals R1-R5 all test/doc → this commit; re-reviewer pre-authorized lead diff-check close) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). **Re-review residual round (2026-08-16, this commit):** the narrow re-review of `0c8e1e7d` found every code fix oracle-verified but returned NOT CLOSED on five test/doc residuals plus nits. R1 — added the missing App-layer regression test (`CharacterCreationUiControllerTests.SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName`) that actually drives the F1 bug shape (external Refresh-driven `SetText` while unfocused, THEN a real `SetText`+`Submit` user commit), since the claimed coverage never touched the page. R2 — added direct `RetailSkillFormula.CalculateChargenScore`/`ChargenSkillScoreResolver` coverage (`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`: a Untrained/Trained/Specialized theory, the divisor-zero skip path, and a six-way `AttributeId` theory), replacing the F12(d) test's `skillId * 10` substitute as the ONLY prior coverage. R3 — MEASURED (not assumed) the installed DAT's SkillTable `MinLevel` distribution (`CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_NeverExceedsTrained`: 23 skills at MinLevel 1, 15 at MinLevel 2, of 38 priced skills, zero above 2) and restated `RetailSkillFormula.cs`'s doc comment around the measured fact instead of the unverified "no skill exceeds Untrained=1" claim — ACE's own hedge (`// 1-2?`) was right; the structural "gate holds for any MinLevel in {1,2}" argument is now the load-bearing one, not the data claim. R4 — filed AP-228 (the Summary/Skills skill-row KEY sourcing from `ItemAppraisalTextFormatter.SkillName`'s hardcoded English switch, where retail's own key is DAT-sourced — same class as AP-226, reversed polarity, also present at CC4's Skills page) and softened AP-224's "ported exactly" claim to note it only ever covered the row's VALUE/template, never its KEY. R5 — this commit's message corrects `0c8e1e7d`'s false "Release build zero warnings" gate claim (18 pre-existing warnings, all in the unrelated `AcDream.Core.Tests` project, none in any project this campaign touched). Plus three nits: the `ChargenPreviewController` ctor doc now also cites `gmCGSummaryPage::Update @0x0047baa0` (the per-heritage re-derive site, not just the one-shot `InitializePage` seed); the F2 inline comment's "Finish becoming a permanent no-op" reworded (`_verificationPending` was already cleared pre-fix too — Finish was never blocked, only the RESPONSE feedback vanished); and #404 filed for `ChargenSkillScoreResolver`'s own independent SkillTable read alongside `ChargenTableReader`'s (cleanup, not urgent — not this round's scope). | | CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | From 9cf6c52283c9397260351125bf6ca6ab7aeeb23c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 02:35:21 +0200 Subject: [PATCH 105/138] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice?= =?UTF-8?q?=20CC7=20=E2=80=94=20end-to-end=20create=20flow=20+=20connected?= =?UTF-8?q?=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create button un-ghosts: retail's exact gate (gmCharacterManagementUI:: UpdateButtons @0x004ec240, roster count < allowed slot count) ported into RuntimeCharacterSelectionButtons.CanCreate; the button's OnClick opens the chargen screen through the same CharacterCreationUiController.Open() seam the ACDREAM_OPEN_CHARGEN=1 dev path already used. Exit/Back confirm on chargen needed no new return-path code — character-management is never hidden while chargen is open on top of it — verified end-to-end by a new cross-controller test rather than left as an inspection claim. Full-flow test coverage: a new comprehensive test decodes every 0xF656 field (including the trailing checksum, recomputed via the production CharacterCreate.ComputeChecksum) against a fully populated creation (heritage/gender/all appearance slots/template/explicit skill command/ town/name); a new Theory drives the remaining six 0xF643 rejection codes through the real wire decode path, closing the gap between the already-covered isolated state-machine Theory and an actual WorldSession round trip. Launcher payload cycle: two new tests drive a real Runtime create/reject through the real SessionStatusWriter (wired exactly as LiveSessionRuntimeFactory/HeadlessSessionHost do in production) and read the result back with the real Launcher.Core StatusFileTailer/ StatusEventParser — closing the one gap CC2's own per-layer tests never reached. No gap was found in production wiring itself: GameWindow already constructs a real, non-null SessionStatusWriter for both hosts. Also fixes 4 pre-existing LiveSessionControllerTests assertions that compared a full RuntimeCharacterSelectionButtons record and would have failed once CanCreate started being computed; corrects register row AP-211 to reflect that its own predicted resolution (the Create-button gate landing) has now happened — both layers are intentionally kept as retail-matching enforcement plus defense-in-depth, not one superseding the other. Adds docs/research/2026-08-16-campaign-cc-test-script.md, the user's connected-gate script covering both the launcher and dev-shortcut launch paths, the six-page create flow, every Finish outcome, and the known cosmetic/behavioral divergences (AP-212/213/215/216/217/218/219/220/222/ 224/226/228) so they aren't mistaken for new bugs during the gate. Gates: full solution Release build green; Runtime 1735/0 (was 1726/0, +9), App 5256/3 skips (was 5254/3, +2), Headless 166/0 (unchanged), Launcher.Core 324/0, one full-solution pass across every project clean (no known flakes reproduced this run). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../2026-08-16-campaign-cc-test-script.md | 342 ++++++++++++++++ .../Layout/CharacterManagementUiController.cs | 30 +- src/AcDream.App/UI/RetailUiRuntime.cs | 30 +- .../Session/RuntimeCharacterSelectionState.cs | 30 +- .../CharacterManagementUiControllerTests.cs | 84 +++- ...CharacterScreensFixedCanvasArbiterTests.cs | 70 +++- .../AcDream.Runtime.Tests.csproj | 7 + ...SessionControllerCharacterCreationTests.cs | 375 +++++++++++++++++- .../Session/LiveSessionControllerTests.cs | 24 +- 10 files changed, 957 insertions(+), 37 deletions(-) create mode 100644 docs/research/2026-08-16-campaign-cc-test-script.md diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 142690a7..32964c5b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -404,7 +404,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | -| AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | +| AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | ## 4. Temporary stopgap (TS) — 49 active rows (TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/research/2026-08-16-campaign-cc-test-script.md b/docs/research/2026-08-16-campaign-cc-test-script.md new file mode 100644 index 00000000..14ca49aa --- /dev/null +++ b/docs/research/2026-08-16-campaign-cc-test-script.md @@ -0,0 +1,342 @@ +# Campaign CC connected-gate test script + +**Status: the campaign is CODE-COMPLETE (CC1-CC7) and this script is its +connected-gate contract.** Every step below is the user's own eyes on the +running client — nothing here was run automatically. **No automated live +character creation has been run against ACE** (see §CC-Not-Automated) — +the first LIVE create is deliberately left to this gate. + +This script covers TWO ways to reach the chargen screen: the real launcher +flow (Campaign LA's product path) and the developer shortcut +(`ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`, still available and still +useful for a fast create-only iteration loop). Both land on the exact same +screen and Runtime owner — there is no second code path being tested. + +Local ACE connection details (per `CLAUDE.md`): host `127.0.0.1`, port +`9000`, account `testaccount` / `testpassword`. Use a FRESH character name +per attempt (ACE does not forget names within a session) — `CC--` +is a good scheme, e.g. `CCAB1`, `CCAB2`. + +--- + +## §CC1 — reaching the screen + +### Path A — the launcher (product path) + +1. Launch `AcDream.Launcher` (already installed/updated per Campaign LA's + own gates — this script does not re-run first-run setup or the update + flow; see `docs/research/2026-08-14-campaign-la-test-script.md` if either + is in question). +2. Open **Check for updates**. Confirm it reports the client already + current (no install prompt) — Campaign LA's own gates already proved the + install/update mechanics; this step just confirms nothing is stale + before the character-creation gate. +3. Confirm (or create) a profile pointed at `127.0.0.1:9000`, + `testaccount` / `testpassword`. +4. Click **GUI — character select**. The retail character-management + screen (`gmCharacterManagementUI`) opens — the flat character list, World + name, Enter/Delete/Restore buttons, and the **Create** button. + +### Path B — the developer shortcut + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_RETAIL_UI = "1" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release +``` + +Confirm the SAME character-management screen appears. `ACDREAM_OPEN_CHARGEN=1` +(add it to the block above) is the CC4-era interim seam that opens the +chargen screen directly on startup, skipping the Create click — still +useful for a fast create-only loop, but Path A/B above is now the REAL path +and should be exercised at least once per gate. + +### The Create button + +1. With at least one free character slot (roster count below the account's + allowed slot count — most test accounts have several free slots), + confirm **Create** is ENABLED (not greyed out). +2. **Click Create.** The chargen screen (`gmCharGenMainUI`) opens directly + on the **Heritage** page — no confirmation, no loading screen. The + character-management screen you were just on is not closed or hidden; + it simply sits behind the new screen (this matters for the Exit step + below). +3. **If your account's roster is completely full** (rare on a fresh test + account — every slot occupied), confirm Create is instead GREYED OUT + and does nothing when clicked. This is retail's own gate + (`gmCharacterManagementUI::UpdateButtons`) — a full roster ghosts + Create exactly like Enter/Delete grey out for an unselected row. + +### Leaving the screen (Back-at-Heritage and Exit) + +1. On the **Heritage** page (the first page), click **Back**. Confirm a + confirmation dialog appears asking whether to leave character creation + (same text/shape as the Exit button below — Back-at-Heritage and Exit + share retail's one `DoExit` confirmation). +2. Click **Exit** (top of the screen, available on every page). Confirm + the SAME confirmation dialog appears. +3. **Confirm the dialog (accept).** The chargen screen closes. You are back + at the character-management screen you started from — roster, World + name, and any prior selection are exactly as you left them (character + management was never hidden, so there is nothing to "restore"). +4. Click **Cancel** on a repeat Exit attempt instead — confirm the dialog + closes and chargen stays open, untouched. + +### What to report for §CC1 + +- Create's enabled/greyed state matching the free-slot count you actually + have. +- Whether clicking Create opens chargen with no lag/flash/black frame. +- Whether returning from Exit shows the character list exactly as it was + (no re-flicker, no lost highlight, no stale World name). + +--- + +## §CC2 — the six-page create flow + +**The screen does NOT open blank.** `gmCharGenMainUI`'s own constructor +rolls a full random character (heritage, gender, appearance, clothing, +template, start area) before the Heritage page ever draws — acdream ports +this faithfully (AP-214, retired). **Expected retail quirk: the gender +shown on the Appearance page is the FLIP of the roll** — the Appearance +page's own init code reads the just-rolled gender and immediately swaps it +to the opposite one. If you open chargen and see (say) a female Aluvian +with the Appearance page showing "Male" selected, that is CORRECT, not a +bug — do not report it. + +### Heritage page + +1. Confirm one of the 13 heritage buttons is already highlighted (the + opening roll) — Human heritages (Aluvian/Gharu'ndim/Sho/Viamontian), + Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, + Olthoi, and OlthoiAcid should all be selectable and each show its own + description text (starting skills, bonus-skills paragraph where retail + has one — Lugian/Olthoi/OlthoiAcid have none, that's retail-correct). +2. Click **Random**. Confirm the highlighted heritage changes to a + uniformly-picked one of the 13 — this is AP-212's documented + approximation (retail's own Heritage-page Random rolls with retail's own + distribution; acdream picks uniformly over every installed heritage). + Not a bug to report unless the button does nothing or crashes. +3. Select **Olthoi** or **OlthoiAcid**. Confirm the Profession, Skills, and + Town tabs are hidden (Olthoi variants skip straight to a fixed Custom + template with no attribute/skill/town choices) and the screen + auto-advances past them. + +### Profession page + +1. Select a NON-Olthoi heritage. Confirm 7 template buttons (Custom + 6 + presets) and 6 attribute sliders (Strength/Endurance/Coordination/ + Quickness/Focus/Self). +2. Drag a slider. Confirm the numeric readout updates live and the + Available-credits counter decreases/increases correspondingly. +3. Click a preset template (not Custom). Confirm all 6 sliders jump to + that preset's values and Available credits updates to match. +4. Click **Random**. Confirm heritage/template selection changes (AP-212 — + uniform pick, not retail's own weighted roll). + +### Skills page + +1. Confirm ONE flat listbox of skills, each row showing name, current + level, and train/specialize costs (AP-213 — retail groups these into + four sorted buckets; acdream's flat list is a presentation + simplification, not a rules difference — do not report the flat + ordering as a bug). +2. Click a trainable row. Confirm it advances (Untrained -> Trained -> + Specialized) and the skill-credits meter decreases; a second click on an + already-Specialized row does nothing further (no fourth state). +3. Confirm **Random** is disabled/greyed on this page (retail's + `RandomizeSkills` primitive is unported — AP-212's own documented gap). + +### Appearance page + +1. Confirm **Face** and **Clothes** sub-tabs, nine spin controls (hair, + eyes, nose, mouth, skin under Face; headgear, shirt, trousers, footwear + under Clothes), nine color swatches, a shade scrollbar, zoom/rotate + buttons, and a live 3D preview playing an idle animation loop. +2. Click a spin's left/right arrow zones. Confirm the selected style index + advances/retreats and the preview model updates. +3. Click a color swatch. Confirm the swatch shows a highlighted "selected" + ring/border (AP-215 — acdream's own selection indicator, not retail's + overlay mechanism; functionally equivalent). +4. Click **Zoom In**. Confirm the preview freezes its pose (idle animation + stops) and the camera tweens closer over about half a second. Click + **Zoom Out** — animation resumes, camera tweens back out. +5. Click **Rotate Clockwise**/**Counter-Clockwise**. Confirm the model + spins continuously at a steady rate (about 3 seconds per full turn); + clicking the SAME direction again stops it, clicking the OPPOSITE + direction reverses it. +6. Click **Random** (on either sub-tab). Confirm hair/eyes/nose/mouth/skin + (Face) or headgear/shirt/trousers/footwear (Clothes) all re-roll + together — this IS retail's real `RandomizeAppearance`/ + `RandomizeClothing` primitive (CC5 ported it verbatim, not approximated). +7. Select **Gearknight**, **Olthoi**, or **OlthoiAcid** on the Heritage + page, then return to Appearance. Confirm the Clothes sub-tab and its + four spins are unreachable, Nose/Mouth spins are hidden, and Eyes' + arrows are disabled (fixed eyes for these forms). + +Known cosmetic gaps on this page — expected, do not report as bugs unless +noticeably worse than described: swatches show static art rather than the +actual color they represent (AP-216); the gradient circle art next to the +swatches never repaints to reflect the current color (AP-217); the four +icon-only spins (hair/eyes/nose/mouth) show a plain number instead of an +icon thumbnail, while the four clothing spins show real names (AP-215/ +AP-218); on Olthoi/OlthoiAcid/Gearknight the Skin spin does not slide up to +close the gap left by the hidden Nose/Mouth spins (AP-219); switching +heritage INTO or OUT OF Gearknight does not automatically re-roll +appearance/clothing the way retail does on that exact transition (AP-220); +the currently-selected spin shows no distinct highlighted state versus the +other eight (AP-222 — this is a MEASURED gap in acdream's own art, not yet +attributed to a specific missing asset; report clearly if you can visually +compare with retail here). + +### Town page + +1. Confirm four town buttons: Holtburg, Shoushi, Yaraq, Sanamar (not id + order — that's retail's own literal ordering, ported faithfully), each + with descriptive text. +2. Click a town. Confirm it highlights and the description text updates. + +### Summary page + +1. Confirm a listbox showing Profession, Gender, Heritage, and Starting + Town lines, an "Attributes" header, then Strength/Endurance/ + Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits as + paired rows, then Specialized and Trained skill name lists (retail also + lists the two Untrained buckets; acdream's Summary omits them — + AP-224, same class of cut as the Skills page's own AP-213). +2. Confirm a static 3D preview of the character (no zoom/rotate controls + on this page — retail has none here either). +3. Click the **name field** and type a name. Confirm only letters, spaces, + apostrophes, and hyphens are accepted (other characters are silently + rejected keystroke-by-keystroke). +4. Click **Random** on this page. Confirm a confirmation dialog appears + first ("are you sure you want to randomize?"); confirming it re-rolls + the ENTIRE character (heritage through name) using retail's real + `RandomizeCharacter` primitive — the same one the screen-open roll uses. + +### What to report for §CC2 + +- Any page that fails to render a control listed above, or where a control + visibly does nothing when clicked. +- Anything from the "known cosmetic gaps" list that looks MORE broken than + described (e.g. a spin that doesn't advance at all, not just a missing + highlight). +- Any crash, freeze, or console error while navigating pages or the tabs. + +--- + +## §CC3 — Finish and its dialogs + +Use a fresh, never-before-used character name for the happy path. For every +scenario below, watch the console/log for `[UI]`/`[CC]`-prefixed lines — +they help distinguish "nothing happened because the click didn't register" +from "the request went out and ACE is thinking about it." + +### Happy path + +1. Complete a legal character (heritage, gender, template with credits + fully spent, at least a default set of skills, a town, a fresh name). +2. Click **Finish** on the Summary page. Confirm the screen closes almost + immediately (no visible "please wait" dialog for a normal accept — ACE's + Ok reply is fast) and you land DIRECTLY in the world as the new + character — no return to character management, no fresh character list, + matching retail's own "log straight in" behavior. +3. If you back out to character management instead (e.g. via a later + logout), confirm the new character now appears in the roster alongside + any pre-existing ones, in the correct slot. + +### NameInUse (duplicate name) + +1. Create a SECOND character using the EXACT name you just used above. +2. Click Finish. Confirm the `ID_Character_Err_NameReserved` dialog appears + ("that name is in use" / similar text) and you stay on the chargen + screen — Finish is clickable again afterward. +3. **Expected log noise (register AD-100):** ACE sends the NameInUse + rejection TWICE for the same request (a real ACE double-send bug, not + an acdream defect). The FIRST reply drives the dialog above; the SECOND + logs something like `unexpected CharacterGenerationVerificationResponse` + in the console. That log line is EXPECTED here — do not report it as an + error. + +### The credit-warning confirm flow + +1. Select the **Custom** template on the Profession page (leaves several + attribute credits unspent) and complete the rest of the character. +2. Click Finish. Confirm a warning dialog appears about unspent attribute + credits, and Finish does NOT send anything yet. +3. Confirm the dialog. Confirm the request now sends anyway, with the + unspent credits — this is retail-correct (`DoFinish`'s own confirm-arm + skips the credit check entirely; ACE accepts an under-spent build). + +### The randomize warning flow + +Already covered in §CC2's Summary-page step 4 above — confirm the warning +appears BEFORE any randomization happens, and Cancel leaves the character +completely untouched. + +### The exit warning flow + +Already covered in §CC1's "Leaving the screen" section above. + +### NameTooLong + +1. On the Summary page, type or paste a name longer than 32 characters and + commit it (press Enter, or click elsewhere to move focus away from the + field). +2. Confirm the `ID_CharGen_NameTooLong` dialog appears and the field + reverts to its previous (shorter) value — the field itself does not cap + your typing at 32 characters as you go; the rejection only fires on + commit. That is retail-correct, not a bug. + +### What to report for §CC3 + +- Whether the happy path truly lands you in-world with no intermediate + screen. +- The exact dialog text shown for each rejection (useful for a later + string-table audit even if it looks right). +- Any case where Finish appears to do nothing at all (no dialog, no + console line, no world entry) — that would be a real regression, not one + of the documented cosmetic gaps above. + +--- + +## §CC4 — ACE-side landmines (not acdream defects) + +- **Heritage-priced skill over-deduction — LATENT, will not fire with the + installed EoR DAT.** ACE's `PlayerFactory.CreatePlayer` has a real + overcharge bug when specializing a skill priced by the active heritage's + own skill list (versus the global skill table). It was measured against + the installed EoR data and found unreachable — every heritage's one + priced skill (Arcane Lore) has a heritage NormalCost of 0, which makes + ACE's overcharge exactly zero. You should NOT be able to trigger a + `FailedToSpecializeSkill` rejection from a retail-legal build during this + gate. If you somehow do, that is worth flagging immediately — it would + mean the installed DAT's costs differ from what was measured. +- **Disabled-Olthoi create -> Pending -> NameDBDown dialog is + retail-correct.** If your local ACE has Olthoi character creation + disabled (a server config option), attempting to create an Olthoi/ + OlthoiAcid character will surface the `ID_Character_Err_NameDBDown` + dialog via a `Pending` response code. This is retail's OWN behavior + (ACE's `olthoi_play_disabled` branch sends `Pending`, and retail's + dispatch has no silent branch for it) — not a "the client swallowed my + request" bug. + +--- + +## §CC-Not-Automated + +No automated test in this repository has created a character against a +LIVE ACE server. Every field-shape and response-code assertion in Campaign +CC's test suite runs against a real `WorldSession` and hand-built response +packets (see `tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`) +— the wire bytes and the state machine are proven byte-for-byte, but the +FIRST character ever created against a real, running ACE process is +whatever you create during this gate. Server state (what names exist, +what heritages are enabled, what the account's slot count is) is entirely +yours to observe; this script deliberately does not assume any of it in +advance. diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index dc6e9be2..963d5e23 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -125,12 +125,16 @@ internal sealed class CharacterManagementUiController : IDisposable Root.Width > 0f ? Root.Width : 800f, Root.Height > 0f ? Root.Height : 600f); - // Create Character belongs to a future campaign. Keep retail's - // authored control in place and visibly ghosted; do not hide it or - // invent an action. + // Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage + // @ 0x004ed5a0 case 3 dispatches Create unconditionally on click + // (QueueUIMode(0x1000000b) — no gate at click time); the gate lives + // entirely in UpdateButtons @ 0x004ec240's own Enabled/ghosted state + // (see ApplyButtons below), so the click handler is wired once here + // and Enabled tracks the borrowed snapshot every tick. Starts + // disabled/ghosted until the first real snapshot arrives. _create.Visible = true; _create.Enabled = false; - _create.OnClick = null; + _create.OnClick = RequestCreate; _enter.OnClick = EnterSelected; _delete.OnClick = RequestDelete; _restore.OnClick = RestoreSelected; @@ -534,7 +538,7 @@ internal sealed class CharacterManagementUiController : IDisposable private void ApplyButtons(RuntimeCharacterSelectionButtons buttons) { _create.Visible = true; - _create.Enabled = false; + _create.Enabled = buttons.CanCreate; _enter.Enabled = buttons.CanEnter; _delete.Visible = buttons.DeleteVisible; _delete.Enabled = buttons.CanDelete; @@ -550,6 +554,22 @@ internal sealed class CharacterManagementUiController : IDisposable InvalidateAndTick(); } + /// + /// Campaign CC slice CC7: ListenToElementMessage case 3 -> + /// QueueUIMode(0x1000000b). Purely presentational — no Runtime + /// command, no roster/state change here; ' + /// RequestCreate is resolved per-call (never captured) so it + /// reflects whatever wired at the time of + /// the click, matching every other late-bound seam in this bindings + /// record. + /// + private void RequestCreate() + { + if (_disposed) + return; + _bindings.RequestCreate?.Invoke(); + } + private void EnterSelected() { if (_disposed) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 22f7a199..07381f1d 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -398,7 +398,24 @@ public sealed record CharacterSelectionRuntimeBindings( Func ConfirmDelete, Func Restore, Func Cancel, - Action RequestExit); + Action RequestExit, + /// + /// Campaign CC slice CC7: retail's Create button + /// (gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0 + /// case 3 -> UIFramework::QueueUIMode(this, 0x1000000b), the + /// gmCharGenMainUI mode). Wired by + /// itself (it alone holds both the character-management and + /// character-creation controllers) to + /// CharacterCreationController?.Open() — resolved lazily so + /// mount order between the two screens does not matter. + /// when no chargen screen is mounted (e.g. a + /// headless bot's LiveCharacterSelector path, where + /// is also + /// null) — the button then behaves as a no-op click while its own + /// Enabled gate () + /// still reflects the real roster-vs-slot state. + /// + Action? RequestCreate = null); public sealed record RetailUiRuntimeBindings( UiHost Host, @@ -3816,9 +3833,18 @@ public sealed class RetailUiRuntime : IDisposable if (bindings is null || _characterManagementMount is not null) return; + // Campaign CC slice CC7: RetailUiRuntime is the one object holding + // BOTH controllers, so it supplies the cross-screen seam locally + // rather than routing it through the externally-composed bindings + // record (which is built before this runtime exists — see + // CharacterSelectionRuntimeBindings.RequestCreate's own doc + // comment). The lambda closes over `this` and reads + // CharacterCreationController per call, so it is safe even though + // ConfigureCharacterCreation() has not run yet at this point (see + // its call site immediately below this method's own caller). _characterManagementMount = new CharacterManagementUiMountCoordinator( Host.Root, - bindings, + bindings with { RequestCreate = () => CharacterCreationController?.Open() }, EnsureDialogFactory, LoadCharacterManagementResources); } diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs index 2c289825..53adf97c 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs @@ -64,7 +64,17 @@ public readonly record struct RuntimeCharacterSelectionButtons( bool CanDelete, bool CanRestore, bool DeleteVisible, - bool RestoreVisible) + bool RestoreVisible, + /// + /// Campaign CC slice CC7: retail's Create-character gate + /// (gmCharacterManagementUI::UpdateButtons @ 0x004ec240, + /// ~0x004ec319-0x004ec32e) — unconditional on selection, purely + /// _charSet.set_.m_num < _charSet.numAllowedCharacters_ (the + /// live roster count against the allowed-slot ceiling). Mirrors + /// < + /// . + /// + bool CanCreate = false) { public static RuntimeCharacterSelectionButtons None { get; } = new(false, false, false, true, false); @@ -848,13 +858,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex) { + // gmCharacterManagementUI::UpdateButtons @ 0x004ec240's Create gate + // is unconditional on the delete/selection state below it — it only + // ever compares the live roster count against the allowed-slot + // ceiling (~0x004ec319-0x004ec32e: + // `if (_charSet.set_.m_num < _charSet.numAllowedCharacters_) + // SetState(1); else SetState(0xd);`). + bool canCreate = _entries.Length < _slotCount; + if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested or RuntimeCharacterSelectionOperation.DeleteAcknowledged) { - return RuntimeCharacterSelectionButtons.None; + return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate }; } if (selectedIndex < 0) - return RuntimeCharacterSelectionButtons.None; + return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate }; RuntimeCharacterSelectionEntry selected = _entries[selectedIndex]; if (selected.IsPendingDelete) @@ -864,7 +882,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable CanDelete: false, CanRestore: !_restoreResponseArmed, DeleteVisible: false, - RestoreVisible: true); + RestoreVisible: true, + CanCreate: canCreate); } return new RuntimeCharacterSelectionButtons( @@ -872,7 +891,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable CanDelete: selected.CanEnter, CanRestore: false, DeleteVisible: true, - RestoreVisible: false); + RestoreVisible: false, + CanCreate: canCreate); } private int FindDisplayIndex(uint characterId) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index 7e884c59..ec21c062 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -61,8 +61,12 @@ public sealed class CharacterManagementUiControllerTests CharacterManagementUiController.RestoreElementId); Assert.True(create.Visible); - Assert.False(create.Enabled); - Assert.Null(create.OnClick); + // Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @ + // 0x004ec240's Create gate — 3 characters against SlotCount 5. + Assert.True(create.Enabled); + Assert.NotNull(create.OnClick); + create.OnClick!(); + Assert.Equal(1, environment.Runtime.RequestCreateCalls); Assert.True(enter.Enabled); Assert.True(delete.Visible); Assert.True(delete.Enabled); @@ -99,6 +103,45 @@ public sealed class CharacterManagementUiControllerTests Assert.True(restore.Enabled); } + /// + /// Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @ + /// 0x004ec240's Create gate (~0x004ec319-0x004ec32e) is purely + /// _charSet.set_.m_num < _charSet.numAllowedCharacters_ — a + /// full roster (roster count == the allowed-slot ceiling) ghosts Create + /// exactly like retail, and refilling below the ceiling un-ghosts it + /// again on the next Tick. + /// + [Fact] + public void CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt() + { + using var environment = new EnvironmentHarness(); + CharacterManagementUiController controller = environment.Controller; + UiButton create = environment.Button( + CharacterManagementUiController.CreateElementId); + + // The fixture's SlotCount is 5 — five characters exactly fills it. + RuntimeCharacterSelectionEntry[] full = Enumerable.Range(0, 5) + .Select(index => new RuntimeCharacterSelectionEntry( + index, + (uint)(0x50000200 + index), + $"Full {index:D2}", + 0u)) + .ToArray(); + environment.Runtime.ReplaceRoster(full, highlightedCharacterId: full[0].CharacterId); + controller.Tick(); + + Assert.True(create.Visible); + Assert.False(create.Enabled); + + RuntimeCharacterSelectionEntry[] belowCeiling = full[..4]; + environment.Runtime.ReplaceRoster( + belowCeiling, + highlightedCharacterId: belowCeiling[0].CharacterId); + controller.Tick(); + + Assert.True(create.Enabled); + } + /// /// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120 /// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName() @@ -906,7 +949,8 @@ public sealed class CharacterManagementUiControllerTests ConfirmDelete, Restore, Cancel, - RequestExit); + RequestExit, + RequestCreate); } public FakeView View { get; } = new(); @@ -918,6 +962,14 @@ public sealed class CharacterManagementUiControllerTests public int CancelCalls { get; private set; } public int RestoreCalls { get; private set; } public int RequestExitCalls { get; private set; } + public int RequestCreateCalls { get; private set; } + + /// Campaign CC slice CC7: the fixture's fixed allowed-slot + /// ceiling — mirrors Snapshot's own hard-coded + /// SlotCount: 5 so computes the SAME + /// roster-vs-slot gate the real RuntimeCharacterSelectionState.BuildButtons + /// does, instead of a fixture-only shortcut. + private const int SlotCount = 5; public RuntimeCommandStatus RestoreStatus { get; set; } = RuntimeCommandStatus.Accepted; public bool ThrowOnRestore { get; set; } @@ -929,7 +981,8 @@ public sealed class CharacterManagementUiControllerTests RuntimeCharacterSelectionButtons buttons = operation is RuntimeCharacterSelectionOperation.DeleteRequested or RuntimeCharacterSelectionOperation.DeleteAcknowledged - ? RuntimeCharacterSelectionButtons.None + ? RuntimeCharacterSelectionButtons.None with + { CanCreate = View.Entries.Length < SlotCount } : ButtonsFor(View.Snapshot.HighlightedCharacterId); Update(snapshot => snapshot with { @@ -1022,7 +1075,8 @@ public sealed class CharacterManagementUiControllerTests { PendingDeleteCharacterId = 0u, Operation = RuntimeCharacterSelectionOperation.DeleteRequested, - Buttons = RuntimeCharacterSelectionButtons.None, + Buttons = RuntimeCharacterSelectionButtons.None with + { CanCreate = View.Entries.Length < SlotCount }, }); return Result(RuntimeCommandStatus.Accepted, id); } @@ -1045,7 +1099,8 @@ public sealed class CharacterManagementUiControllerTests false, false, false, - true), + true, + View.Entries.Length < SlotCount), }); AfterRestoreProjection?.Invoke(); return Result(RuntimeCommandStatus.Accepted, id); @@ -1065,13 +1120,22 @@ public sealed class CharacterManagementUiControllerTests private void RequestExit() => RequestExitCalls++; + private void RequestCreate() => RequestCreateCalls++; + + /// Campaign CC slice CC7: mirrors + /// RuntimeCharacterSelectionState.BuildButtons's own + /// unconditional CanCreate computation — roster length + /// against — so every branch below carries + /// the SAME real gate the production state machine does, not a + /// fixture-only shortcut. private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId) { + bool canCreate = View.Entries.Length < SlotCount; RuntimeCharacterSelectionEntry? selected = View.Entries .Cast() .FirstOrDefault(entry => entry?.CharacterId == characterId); if (selected is null) - return RuntimeCharacterSelectionButtons.None; + return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate }; if (selected.Value.IsPendingDelete) { return new RuntimeCharacterSelectionButtons( @@ -1079,14 +1143,16 @@ public sealed class CharacterManagementUiControllerTests false, true, false, - true); + true, + canCreate); } return new RuntimeCharacterSelectionButtons( true, true, false, true, - false); + false, + canCreate); } private void Update( diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs index 452d5ff0..764c87f9 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs @@ -78,6 +78,48 @@ public sealed class CharacterScreensFixedCanvasArbiterTests Assert.Null(environment.Host.FixedCanvasSize); } + /// + /// Campaign CC slice CC7 item 1: the real Create-button wire — retail's + /// gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0 + /// case 3 -> QueueUIMode(0x1000000b) — and the return path on + /// chargen Exit (DoExit @ 0x004e8650 -> + /// QueueUIMode(0x1000000a)). Character-management is never + /// hidden by chargen opening on top of it (see the canvas-arbiter test + /// above), so "return to character management" needs no separate + /// Runtime action beyond chargen's own Close() — this proves + /// that architecture claim end to end rather than by inspection alone. + /// + [Fact] + public void CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement() + { + using var environment = new TwoControllerHarness(); + + // Character-management is active and visible before Create is ever + // clicked (AttachAndTick already ran in its own harness ctor). + Assert.True(environment.Management.Controller.Root.Visible); + Assert.False(environment.Chargen.Controller.Root.Visible); + + UiButton create = environment.Management.Button( + CharacterManagementUiController.CreateElementId); + Assert.True(create.Enabled); + create.OnClick!(); + environment.Chargen.Controller.Tick(); + + Assert.True(environment.Chargen.Controller.Root.Visible); + // Character-management stays active/visible underneath -- chargen + // opening on top never deactivates or hides it. + Assert.True(environment.Management.Controller.Root.Visible); + + environment.Chargen.Button(CharacterCreationUiController.ExitElementId) + .OnClick!(); + environment.Chargen.ConfirmActiveDialog(confirmed: true); + + Assert.False(environment.Chargen.Controller.Root.Visible); + // No separate "return" action was needed -- management was never + // hidden, so it is simply what remains visible. + Assert.True(environment.Management.Controller.Root.Visible); + } + // ── Fixture: one shared UiRoot, both controllers ──────────────────── private sealed class TwoControllerHarness : IDisposable @@ -85,8 +127,15 @@ public sealed class CharacterScreensFixedCanvasArbiterTests public TwoControllerHarness() { Host = new UiRoot { Width = 800f, Height = 600f }; - Management = new ManagementHarness(Host); + // Campaign CC slice CC7: chargen must exist FIRST so + // ManagementHarness can wire its Create button straight to the + // real CharacterCreationUiController.Open() — the same shape + // RetailUiRuntime.ConfigureCharacterManagement() uses in + // production (a lazily-resolved lambda closing over the OTHER + // controller, since bindings are always built before both + // controllers exist). Chargen = new ChargenHarness(Host); + Management = new ManagementHarness(Host, Chargen.Controller.Open); } public UiRoot Host { get; } @@ -104,17 +153,17 @@ public sealed class CharacterScreensFixedCanvasArbiterTests { private readonly RetailDialogFactory _dialogs; - public ManagementHarness(UiRoot host) + public ManagementHarness(UiRoot host, Action requestCreate) { - ImportedLayout screen = BuildManagementScreen(); - Runtime = new ManagementFakeRuntime(); + Screen = BuildManagementScreen(); + Runtime = new ManagementFakeRuntime(requestCreate); _dialogs = new RetailDialogFactory( host, type => RetailDialogFactoryTests.BuildDialogLayout(type)); Controller = Assert.IsType( CharacterManagementUiController.Bind( host, - screen, + Screen, static (_, _) => BuildRow(), _dialogs, Runtime.Bindings, @@ -126,9 +175,13 @@ public sealed class CharacterScreensFixedCanvasArbiterTests "Are you sure you want to leave?"))); } + public ImportedLayout Screen { get; } public ManagementFakeRuntime Runtime { get; } public CharacterManagementUiController Controller { get; } + public UiButton Button(uint id) => + Assert.IsType(Screen.FindElement(id)); + public void Dispose() { Controller.Dispose(); @@ -182,7 +235,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests private static readonly RuntimeGenerationToken Generation = new(11u); private readonly FakeManagementView _view = new(); - public ManagementFakeRuntime() + public ManagementFakeRuntime(Action requestCreate) { _view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)]; _view.Snapshot = new RuntimeCharacterSelectionSnapshot( @@ -199,7 +252,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests LastRestoreRequestedCharacterId: 0u, Operation: RuntimeCharacterSelectionOperation.None, Error: null, - Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false)); + Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true)); Bindings = new CharacterSelectionRuntimeBindings( View: () => _view, Highlight: _ => Result(), @@ -208,7 +261,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests ConfirmDelete: Result, Restore: Result, Cancel: Result, - RequestExit: () => { }); + RequestExit: () => { }, + RequestCreate: requestCreate); } public CharacterSelectionRuntimeBindings Bindings { get; } diff --git a/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj b/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj index fd27c566..868e48f2 100644 --- a/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj +++ b/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj @@ -18,5 +18,12 @@ + + diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs index 91ebbdb4..0f07ee80 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -6,6 +6,7 @@ using AcDream.Core.CharGen; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; +using AcDream.Launcher.Core.Status; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Runtime.Tests.CharGen; @@ -124,6 +125,17 @@ public sealed class LiveSessionControllerCharacterCreationTests public List Created { get; } = []; public List Failed { get; } = []; + /// + /// Campaign CC slice CC7 item 3: when set, forwards exactly the way + /// production hosts do (LiveSessionRuntimeFactory.Create's + /// own CharacterCreated/CreationFailed delegates, + /// HeadlessSessionHost's identical pair) — the SAME real + /// a launcher-composed session + /// would use, not a re-implemented shape. + /// + public SessionStatusWriter? Writer { get; set; } + public string SessionId { get; set; } = "s1"; + public LiveSessionBinding BindSession(WorldSession session) => new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { }); public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { } @@ -134,10 +146,17 @@ public sealed class LiveSessionControllerCharacterCreationTests public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => EnteredWorld.Add(selection); public void DetachSession(WorldSession session) { } - public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => + public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) + { Created.Add(identity); - public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => + Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name); + } + public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) + { Failed.Add(rejection); + Writer?.CreationFailed( + SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName); + } } private static LiveSessionConnectOptions LiveOptions() => new( @@ -399,6 +418,259 @@ public sealed class LiveSessionControllerCharacterCreationTests Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged. } + /// + /// Campaign CC slice CC7 item 2: the SEAMLESS end-to-end walk the + /// campaign closeout asks for — a fully populated creation (heritage, + /// gender, appearance across every one of the fourteen style/color + /// slots and all six shades, template, an EXPLICIT skill command beyond + /// what the template alone applies, an explicit town/start-area + /// selection, name) sent through a REAL , then + /// decoded field-by-field — including the trailing checksum, which the + /// pre-existing + /// test above never checked — against exactly the shape + /// CharacterCreateInfo.Unpack/Appearance.Unpack parse (see + /// 's own doc comment for the ACE + /// cross-reference). The checksum is recomputed via the SAME production + /// formula rather than + /// re-deriving the sum a second time by hand in the test. + /// + [Fact] + public void Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape() + { + (LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + + Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted); + Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyesStrip, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.NoseStrip, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.MouthStrip, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairStyle, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairColor, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyeColor, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearColor, 2u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtColor, 1u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersColor, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearStyle, 0u).Accepted); + Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearColor, 2u).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Skin, 0.25).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Hair, 0.5).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Headgear, 0.75).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Shirt, 0.1).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Trousers, 0.9).Accepted); + Assert.True(controller.SetShade(generation, ChargenShadeSlot.Footwear, 0.6).Accepted); + Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted); + // Explicit skill command beyond the template's own Normal/Primary + // lists (SkillFreeTrained costs 0 to train — no credit-budget risk). + Assert.True(controller.TrainSkill(generation, RuntimeCharacterCreationStateFixture.SkillFreeTrained).Accepted); + // Town: the fixture's global starter-area list is [Holtburg(0), Yaraq(1)]. + Assert.True(controller.SelectStartArea(generation, 1).Accepted); + Assert.True(controller.SetName(generation, "FullChar").Accepted); + + WorldSession session = operations.Sessions[0]; + byte[]? captured = null; + session.GameMessageCapture = (body, _) => captured = body; + + Assert.True(controller.Finish(generation).Accepted); + Assert.NotNull(captured); + + DecodedFullRequest decoded = DecodeCreateRequestFull(captured!); + + Assert.Equal("testaccount", decoded.AccountName); + Assert.Equal(1u, decoded.Constant); + CharacterCreate.Request r = decoded.Request; + Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, r.Heritage); + Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, r.Gender); + Assert.Equal(0u, r.Appearance.EyesStrip); + Assert.Equal(0u, r.Appearance.NoseStrip); + Assert.Equal(0u, r.Appearance.MouthStrip); + Assert.Equal(1u, r.Appearance.HairColor); + Assert.Equal(1u, r.Appearance.EyeColor); + Assert.Equal(1u, r.Appearance.HairStyle); + Assert.Equal(0u, r.Appearance.HeadgearStyle); + Assert.Equal(2u, r.Appearance.HeadgearColor); + Assert.Equal(0u, r.Appearance.ShirtStyle); + Assert.Equal(1u, r.Appearance.ShirtColor); + Assert.Equal(0u, r.Appearance.TrousersStyle); + Assert.Equal(0u, r.Appearance.TrousersColor); + Assert.Equal(0u, r.Appearance.FootwearStyle); + Assert.Equal(2u, r.Appearance.FootwearColor); + Assert.Equal(0.25, r.Appearance.SkinShade); + Assert.Equal(0.5, r.Appearance.HairShade); + Assert.Equal(0.75, r.Appearance.HeadgearShade); + Assert.Equal(0.1, r.Appearance.ShirtShade); + Assert.Equal(0.9, r.Appearance.TrousersShade); + Assert.Equal(0.6, r.Appearance.FootwearShade); + Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, r.Template); + Assert.Equal(16u, r.Attributes.Strength); + Assert.Equal(10u, r.Attributes.Endurance); + Assert.Equal(10u, r.Attributes.Coordination); + Assert.Equal(10u, r.Attributes.Quickness); + Assert.Equal(10u, r.Attributes.Focus); + Assert.Equal(10u, r.Attributes.Self); + Assert.Equal(0u, r.Slot); + // classId: register AP-209's documented placeholder — ACE ignores + // this field (retail's DAT DID lookup has no Core equivalent). + Assert.Equal(0u, r.ClassId); + Assert.Equal( + (uint)CharacterCreate.SkillAdvancementClassCount, + (uint)decoded.SkillAdvancementClasses.Length); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Specialized, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]); + Assert.Equal( + (uint)ChargenSkillAdvancementClass.Trained, + decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillFreeTrained]); + Assert.Equal("FullChar", r.Name); + Assert.Equal(1u, r.StartArea); + Assert.False(r.IsAdmin); + Assert.False(r.IsEnvoy); + + // The trailing checksum (CG_Pack@0x005c74c3's final store) — never + // read by ACE, sent for byte fidelity with a genuine retail client. + Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum); + } + + /// + /// Campaign CC slice CC7 item 2: the remaining 0xF643 rejection + /// codes beyond NameInUse (already covered by + /// + /// above) — proves CC5's F2 fix (Pending/Undef produce a real rejection + /// instead of a silent reset) holds over the REAL wire decode path, not + /// just the isolated state-machine + /// RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_... + /// theory. + /// + [Theory] + [InlineData(CharGenVerificationResponse.Code.Pending)] + [InlineData(CharGenVerificationResponse.Code.NameBanned)] + [InlineData(CharGenVerificationResponse.Code.Corrupt)] + [InlineData(CharGenVerificationResponse.Code.DatabaseDown)] + [InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)] + [InlineData(CharGenVerificationResponse.Code.Undef)] + public void Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect( + CharGenVerificationResponse.Code code) + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + + InvokeProcessDatagram(session, BuildResponsePacket((uint)code, 0u, string.Empty)); + + Assert.Single(host.Failed); + Assert.Equal((uint)code, host.Failed[0].RawCode); + Assert.Equal(code, host.Failed[0].Code); + Assert.Equal(code.ToString(), host.Failed[0].Reason); + Assert.Equal("NewChar", host.Failed[0].AttemptedName); + Assert.Empty(host.Created); + Assert.False(controller.IsInWorld); + Assert.Empty(operations.EnterWorldByGuidCalls); + Assert.Empty(host.EnteredWorld); + Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar")); + } + + /// + /// Campaign CC slice CC7 item 3: the launcher payload cycle end to end — + /// a REAL Runtime state transition (Finish -> real + /// -> real inbound 0xF643 Ok reply) + /// through the REAL , wired exactly the + /// way LiveSessionRuntimeFactory.Create (App host) and + /// HeadlessSessionHost wire it (see 's own + /// doc comment), to the REAL Launcher.Core + /// 's parsed event. This is the piece + /// CC2's own tests never reached: SessionStatusWriterTests calls + /// the writer directly and asserts raw JSON; StatusEventParserTests + /// parses a hand-written JSON literal; neither drives a create through + /// Runtime first, so a wiring gap between Runtime's own state machine + /// and the writer (or between the writer's bytes and the tailer's + /// parser) would not have been caught by either. + /// + [Fact] + public void Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer() + { + string path = Path.Combine( + Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl"); + try + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + host.Writer = new SessionStatusWriter(path); + host.SessionId = "cc7-session"; + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar")); + + // Runtime's own side of the contract already fired. + Assert.Single(host.Created); + + var tailer = new StatusFileTailer(path); + IReadOnlyList events = tailer.ReadNewEvents(); + CharacterCreatedStatusEvent created = + Assert.Single(events.OfType()); + Assert.Equal("cc7-session", created.SessionId); + Assert.Equal(0x50001234u, created.Guid); + Assert.Equal("NewChar", created.Name); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + /// Sibling of the Ok test above for the non-Ok half of the + /// contract (creationFailed{code,reason,name}). + [Fact] + public void Finish_ThenNameInUseResponse_WritesCreationFailedEvent_ParsedByTheRealLauncherTailer() + { + string path = Path.Combine( + Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl"); + try + { + (LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) = + StartAwaitingSelection(); + host.Writer = new SessionStatusWriter(path); + host.SessionId = "cc7-session"; + BuildReadyCharacter(controller, generation); + WorldSession session = operations.Sessions[0]; + session.GameMessageCapture = (_, _) => { }; + + Assert.True(controller.Finish(generation).Accepted); + InvokeProcessDatagram(session, BuildResponsePacket( + (uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty)); + + Assert.Single(host.Failed); + + var tailer = new StatusFileTailer(path); + IReadOnlyList events = tailer.ReadNewEvents(); + CreationFailedStatusEvent failed = + Assert.Single(events.OfType()); + Assert.Equal("cc7-session", failed.SessionId); + Assert.Equal((uint)CharGenVerificationResponse.Code.NameInUse, failed.Code); + Assert.Equal("NameInUse", failed.Reason); + Assert.Equal("NewChar", failed.Name); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + private static void InvokeProcessDatagram(WorldSession session, byte[] datagram) { MethodInfo method = typeof(WorldSession).GetMethod( @@ -490,6 +762,105 @@ public sealed class LiveSessionControllerCharacterCreationTests accountName, heritage, gender, template, strength, name, numSkills, skills); } + /// Campaign CC slice CC7: the FULL field set — every byte of + /// 's layout, unlike + /// / + /// above which only samples a handful of fields. + private readonly record struct DecodedFullRequest( + string AccountName, + uint Constant, + CharacterCreate.Request Request, + uint[] SkillAdvancementClasses, + uint Checksum); + + private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan body) + { + int pos = 0; + uint opcode = ReadU32(body, ref pos); + Assert.Equal(CharacterCreate.Opcode, opcode); + string accountName = ReadString16L(body, ref pos); + uint constant = ReadU32(body, ref pos); + uint heritage = ReadU32(body, ref pos); + uint gender = ReadU32(body, ref pos); + uint eyesStrip = ReadU32(body, ref pos); + uint noseStrip = ReadU32(body, ref pos); + uint mouthStrip = ReadU32(body, ref pos); + uint hairColor = ReadU32(body, ref pos); + uint eyeColor = ReadU32(body, ref pos); + uint hairStyle = ReadU32(body, ref pos); + uint headgearStyle = ReadU32(body, ref pos); + uint headgearColor = ReadU32(body, ref pos); + uint shirtStyle = ReadU32(body, ref pos); + uint shirtColor = ReadU32(body, ref pos); + uint trousersStyle = ReadU32(body, ref pos); + uint trousersColor = ReadU32(body, ref pos); + uint footwearStyle = ReadU32(body, ref pos); + uint footwearColor = ReadU32(body, ref pos); + double skinShade = ReadF64(body, ref pos); + double hairShade = ReadF64(body, ref pos); + double headgearShade = ReadF64(body, ref pos); + double shirtShade = ReadF64(body, ref pos); + double trousersShade = ReadF64(body, ref pos); + double footwearShade = ReadF64(body, ref pos); + uint template = ReadU32(body, ref pos); + uint strength = ReadU32(body, ref pos); + uint endurance = ReadU32(body, ref pos); + uint coordination = ReadU32(body, ref pos); + uint quickness = ReadU32(body, ref pos); + uint focus = ReadU32(body, ref pos); + uint self = ReadU32(body, ref pos); + uint slot = ReadU32(body, ref pos); + uint classId = ReadU32(body, ref pos); + uint numSkills = ReadU32(body, ref pos); + var skills = new uint[numSkills]; + for (int i = 0; i < numSkills; i++) + skills[i] = ReadU32(body, ref pos); + string name = ReadString16L(body, ref pos); + uint startArea = ReadU32(body, ref pos); + uint isAdmin = ReadU32(body, ref pos); + uint isEnvoy = ReadU32(body, ref pos); + uint checksum = ReadU32(body, ref pos); + + // Nothing left over, nothing missing — the layout is exhaustive. + Assert.Equal(body.Length, pos); + + var request = new CharacterCreate.Request( + heritage, + gender, + new CharacterCreate.Appearance( + eyesStrip, + noseStrip, + mouthStrip, + hairColor, + eyeColor, + hairStyle, + headgearStyle, + headgearColor, + shirtStyle, + shirtColor, + trousersStyle, + trousersColor, + footwearStyle, + footwearColor, + skinShade, + hairShade, + headgearShade, + shirtShade, + trousersShade, + footwearShade), + template, + new CharacterCreate.Attributes( + strength, endurance, coordination, quickness, focus, self), + slot, + classId, + name, + startArea, + isAdmin != 0u, + isEnvoy != 0u); + + return new DecodedFullRequest(accountName, constant, request, skills, checksum); + } + private static uint ReadU32(ReadOnlySpan body, ref int pos) { uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index b2d752a6..a4495cb3 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -588,8 +588,14 @@ public sealed class LiveSessionControllerTests Assert.Equal( RuntimeCharacterSelectionOperation.DeleteRequested, controller.CharacterSelection.Snapshot.Operation); + // Campaign CC slice CC7: this fixture's roster (2 characters) is + // below its SlotCount (11), so retail's Create gate + // (gmCharacterManagementUI::UpdateButtons) stays enabled through + // the whole delete-request/acknowledge sequence below — CanCreate + // is independent of the delete-in-flight buttons this test is + // actually pinning. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); if (acknowledgeBeforeCompletion) @@ -607,8 +613,10 @@ public sealed class LiveSessionControllerTests ? RuntimeCharacterSelectionOperation.DeleteAcknowledged : RuntimeCharacterSelectionOperation.DeleteRequested, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.True(controller.CharacterSelection.TryGet( 0x50000001u, @@ -625,8 +633,10 @@ public sealed class LiveSessionControllerTests Assert.Equal( RuntimeCharacterSelectionOperation.DeleteAcknowledged, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.Equal([("Canonical", 1)], operations.DeleteRequests); } @@ -665,8 +675,10 @@ public sealed class LiveSessionControllerTests ? RuntimeCharacterSelectionOperation.DeleteAcknowledged : RuntimeCharacterSelectionOperation.DeleteRequested, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.Equal( RuntimeCommandStatus.Rejected, @@ -679,8 +691,10 @@ public sealed class LiveSessionControllerTests Assert.Equal( RuntimeCharacterSelectionOperation.DeleteAcknowledged, controller.CharacterSelection.Snapshot.Operation); + // CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate + // stays true independent of the delete-in-flight buttons. Assert.Equal( - RuntimeCharacterSelectionButtons.None, + RuntimeCharacterSelectionButtons.None with { CanCreate = true }, controller.CharacterSelection.Snapshot.Buttons); Assert.Equal([("Canonical", 1)], operations.DeleteRequests); } From ddcbf1fbf2a045bb329c8ca3dcb23e349b362113 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 02:36:35 +0200 Subject: [PATCH 106/138] =?UTF-8?q?docs:=20Campaign=20CC=20slice=20CC7=20l?= =?UTF-8?q?edger=20row=20+=20campaign=20status=20=E2=80=94=20all=20slices?= =?UTF-8?q?=20code-complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records CC7's implementation (Create-button un-ghosting, the full-flow wire tests, the launcher payload cycle verification, the 4 pre-existing LiveSessionControllerTests fixes, the AP-211 register update, the connected checklist doc) against commit 9cf6c522, and flips the plan's header Status line to reflect all seven slices (CC1-CC7) code-complete pending CC7's own dual-lens review and the user's connected gate. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-15-character-creation-campaign.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 9a42c17f..fd201a59 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -1,6 +1,14 @@ # Campaign CC — retail character creation -**Status:** ACTIVE (started 2026-08-15) +**Status:** All seven slices (CC1-CC7) are CODE-COMPLETE. CC7 (this slice) +closed out the campaign's implementation: the Create button un-ghosts and +opens chargen for real, the full 0xF656/0xF643 flow is proven end-to-end +against a real WorldSession, the launcher status-payload cycle is proven +end-to-end against the real Launcher.Core tailer, and the connected-gate +script is written. CC7's own dual-lens review is OWED, and the user's +connected gate (`docs/research/2026-08-16-campaign-cc-test-script.md`) is +the campaign's remaining acceptance step — no automated live character +creation has been run against ACE (see the script's own §CC-Not-Automated). **Goal (user-set):** the full retail creation flow against local ACE — Create button through a new character entering the world, 3D preview live, rejections showing retail's dialogs — then stop for the user gate. @@ -271,5 +279,5 @@ the user gate. **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | -| CC7 | — | | | | +| CC7 | CODE-COMPLETE 2026-08-16 | `9cf6c522` | OWED (dual-lens review not yet run) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that reorders its shared fixture (chargen constructs first, so its `Open` method exists to wire into management's bindings — the same ordering constraint production code has) and drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum — recomputed via the production `CharacterCreate.ComputeChecksum`, not re-derived by hand a second time in the test, closing the one field (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 fields plus the checksum were previously unverified). `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). | | CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, `d2a71152`, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round `d2a71152` → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of `d2a71152` (R3) after re-deriving from the decomp: `gmCGAppearancePage::ListenToElementMessage`'s own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — `DoGradDisk` is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever `SetColor`/`SetSelection` run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; **the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of `d2a71152`, N2)** — none of the nine spins author Highlight-state media, so `RefreshColorAndShadeControls`'s `TrySetRetailState(Highlight)` call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. | From 2176ba768e443158f7495ddeb4bd8b5f6b130014 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 03:13:02 +0200 Subject: [PATCH 107/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC7=20r?= =?UTF-8?q?eview=20fix=20round=20=E2=80=94=20F1-F9=20=E2=80=94=20REVIEW-CL?= =?UTF-8?q?OSED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both dual-lens reviewers of `9cf6c522`+`ddcbf1fb` returned PASS-with-items. This round closes all nine findings: F1 files AP-229 for the screen-layering divergence (retail destroys/ reconstructs the current UI framework via UIFlow::UseNewMode; acdream keeps both CharacterManagementUiController and CharacterCreationUiController mounted for the whole lifetime and reveals/occludes) plus its narrow residual risk (the shared RetailDialogFactory can hand UiRoot.Modal to a dialog opened by the still-ticking, occluded management screen on an inbound CharacterError) and what already matches retail (selection/ world-name persistence, click-through isolation, one coherent Modal stack). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe and the pending-delete-counts note. F3 adds AP-221's console-diagnostic lines to the known-gaps paragraph. F4 adds an empty-name/AP-227 step. F9 notes that a uniform Random pick over 13 heritages can repeat. F5 adds an App-layer source-text pin (GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBinds CharacterCreatedAndCreationFailedToTheStatusWriter) for the delegate wiring the reviewer proved was deletable without breaking any test — no practical seam exists to construct LiveSessionRuntimeFactory without a GameWindow, so this follows the file's own established source-text-pin pattern; the payload shape is already pinned separately at SessionStatusWriterTests. F6 corrects the CC7 ledger's checksum-assertion wording (it is a round-trip purity check, not an independent golden — the golden is CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet) and cross-references it from the test's own doc comment. F7 corrects the CC7 ledger's fixture-ordering claim (it had chargen constructing first, backwards from RetailUiRuntime.Tick's real management-then-chargen order) and reorders CharacterScreensFixedCanvas ArbiterTests to match production, adding ClickThrough/ZOrder assertions that pin the occlusion the reviewer previously verified only by hand. F8 records a known flake (RuntimeCollisionReportingStateTests. WarmedSteadyContactRefreshDoesNotAllocate, allocation-assertion load sensitivity, pre-existing) seen under full-solution parallel load on both reviewer runs. Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate. Runtime 1735/0 (unchanged), App 5257/3 skips (+1: the new F5 pin). Full Release build: 0 warnings, 0 errors. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../2026-08-15-character-creation-campaign.md | 17 +++-- .../2026-08-16-campaign-cc-test-script.md | 55 +++++++++++++++- .../GameWindowLiveSessionOwnershipTests.cs | 64 +++++++++++++++++++ ...CharacterScreensFixedCanvasArbiterTests.cs | 37 ++++++++--- ...SessionControllerCharacterCreationTests.cs | 18 +++++- 6 files changed, 175 insertions(+), 19 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 32964c5b..31e42d69 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 162 active rows (AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 163 active rows (AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -207,6 +207,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | | AP-228 | **Filed 2026-08-16 at the CC5 re-review residual round (R4).** The Summary listbox's skill-row KEY (the skill's display name) sources from `ItemAppraisalTextFormatter.SkillName(int)` — a hardcoded English `switch` over the 54 skill ids — where retail's own `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` builds that same key from the DAT-sourced `SkillBase->_name` field via a `%hs` format substitution (`data_79f3f0`, `0x0047b90f`-`0x0047b915`). Same divergence CLASS as AP-226 (a hardcoded acdream string standing in for a DAT-sourced retail field) but the polarity is REVERSED: AP-226 is retail-static-vs-acdream-DAT-sourced, while here retail is the DAT-sourced side and acdream is the hardcoded side. The identical pattern is ALSO present at a second call site, CC4's Skills page (`CharacterCreationSkillsPage`), which builds its own row labels through the SAME `ItemAppraisalTextFormatter.SkillName` call — not a second, independent divergence, the same one surfacing twice. | `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs` (`SkillName`), consumed by `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`AddSkillBucket`) and `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` | `SkillName` already backs every OTHER retail skill-name surface acdream has shipped (item-appraisal skill lines, wield-requirement text, usage-limit text — `ItemAppraisalTextFormatter`'s whole existing surface) — the Summary/Skills chargen pages reusing it keeps one skill-name source across the client instead of introducing a second, DAT-reading one for chargen alone. English-only is consistent with the rest of the client's current localization posture (no other surface reads a localized skill name from the DAT either). | A non-English or modded DAT install would show its real, localized skill names on retail's character sheet and item-examine windows but acdream's chargen Summary/Skills pages would keep showing the hardcoded English name regardless — a localization-only divergence, never a wire or gameplay difference (the skill id sent over the wire is unaffected). | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` (`data_79f3f0`, `%hs` substitution `0x0047b90f`-`0x0047b915`) | | AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | | AP-226 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 (the Summary listbox's Profession/Gender/Heritage/Starting Town label sources).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` sources these four labels from four STATIC wide-string tables baked into the binary's data section: `pcProfessions[0x7] @ 0x008191a8` ("Custom", "Bow Hunter", "Swashbuckler", "Life Caster", "War Mage", "Wayfarer", "Soldier"), `pcGender[0x3] @ 0x008191c4` ("?", "Male", "Female"), `pcHeritage[0x5] @ 0x008191d0` ("?", "Aluvian", "Gharu'ndim", "Sho", "Viamontian"), `pcTown[0x4] @ 0x008191e4` ("Holtburg", "Shoushi", "Yaraq", "Sanamar") — each indexed directly by the character's `template_`/`mGender`/`mHeritageGroup`/`startArea` field, each guarded by an upper-bound-only range check (`template_ <= 6`, `mGender <= 2`, `mHeritageGroup <= 4`, `startArea <= 3`) with NO append at all when the index is out of range. Concretely: **`pcHeritage`'s guard is `mHeritageGroup <= 4` — heritage ids 5 and above (every NON-HUMAN heritage: Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, Olthoi, OlthoiAcid) are never appended, so retail's own Summary page renders a BARE `"Heritage: "` with no name at all for a non-human character** — a genuine retail quirk, not a decompiler artifact (confirmed by the same guard shape on all four tables). `CharacterCreationSummaryPage`'s port instead sources every label from the already-loaded `ChargenOptions` DAT model (`heritage.Templates[i].Name`, `gender.Name`, `heritage.Name`, `options.StarterAreas[i].Name`) and prints the literal `"None"` when the index is unresolved, for EVERY heritage including non-human ones — never a bare label. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`ProfessionName`, `GenderName`, `RebuildListbox`'s `"Heritage: " + heritage.Name`, `StarterAreaName`) | The DAT-sourced names are the SAME strings a player already sees on every earlier chargen page (Heritage/Profession/Town pages all source from the identical `ChargenOptions` model) — reusing them keeps the Summary page internally consistent with the rest of the screen rather than introducing a second, static, English-only label source that could drift from the DAT (localization, a modded heritage table) or blank out for heritages retail's own hardcoded table never anticipated. | A pixel-level side-by-side against retail would show a non-human character's Summary "Heritage:" row completely empty of a name in retail (an accepted retail bug/limitation) versus acdream always showing the real heritage name — a cosmetic improvement, never a correctness or wire-format difference; a non-English/modded DAT install could theoretically show acdream a label retail's hardcoded English table never had, which is again strictly more informative, not less. | `pcProfessions[0x7] @0x008191a8`; `pcGender[0x3] @0x008191c4`; `pcHeritage[0x5] @0x008191d0`; `pcTown[0x4] @0x008191e4`; `gmCGSummaryPage::SetSummaryText @0x0047b1d0` (the four guard+append sites) | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index fd201a59..0f3ab4e8 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -1,14 +1,19 @@ # Campaign CC — retail character creation -**Status:** All seven slices (CC1-CC7) are CODE-COMPLETE. CC7 (this slice) +**Status:** All seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is +CODE-COMPLETE pending the user's own connected gate. CC7 (the final slice) closed out the campaign's implementation: the Create button un-ghosts and opens chargen for real, the full 0xF656/0xF643 flow is proven end-to-end against a real WorldSession, the launcher status-payload cycle is proven end-to-end against the real Launcher.Core tailer, and the connected-gate -script is written. CC7's own dual-lens review is OWED, and the user's -connected gate (`docs/research/2026-08-16-campaign-cc-test-script.md`) is -the campaign's remaining acceptance step — no automated live character -creation has been run against ACE (see the script's own §CC-Not-Automated). +script is written. CC7's dual-lens review returned PASS-with-items on both +lenses (findings F1-F9); the F1-F9 fix round closed it out (register row +AP-229, test-script corrections, an App-layer wiring pin, and two ledger +wording corrections — see the CC7 ledger row's own review-fix-round note). +The user's connected gate +(`docs/research/2026-08-16-campaign-cc-test-script.md`) is the campaign's +sole remaining acceptance step — no automated live character creation has +been run against ACE (see the script's own §CC-Not-Automated). **Goal (user-set):** the full retail creation flow against local ACE — Create button through a new character entering the world, 3D preview live, rejections showing retail's dialogs — then stop for the user gate. @@ -279,5 +284,5 @@ the user gate. **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | -| CC7 | CODE-COMPLETE 2026-08-16 | `9cf6c522` | OWED (dual-lens review not yet run) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that reorders its shared fixture (chargen constructs first, so its `Open` method exists to wire into management's bindings — the same ordering constraint production code has) and drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum — recomputed via the production `CharacterCreate.ComputeChecksum`, not re-derived by hand a second time in the test, closing the one field (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 fields plus the checksum were previously unverified). `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). | +| CC7 | REVIEW-CLOSED 2026-08-16 | `9cf6c522`, `ddcbf1fb`, plus the F1-F9 review-fix round (this commit — its own literal sha is not self-referenceable within one commit; append via a tiny follow-up docs commit per the established `bb22ee8b`-style pattern if the literal hash is needed) | CLOSED (dual-lens: both lenses PASS-with-items → F1-F9 fix round this commit; lead diff-check close per the doc/test-only residual pattern) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. **Corrected at the CC7 review-fix round, F7 (2026-08-16): the original fixture-ordering claim above was WRONG.** The shared fixture originally constructed chargen FIRST so its `Open` method existed to wire into management's `RequestCreate` binding — the OPPOSITE of production's real tick order (`RetailUiRuntime.Tick`: `_characterManagementMount?.Tick(); CharacterManagementController?.Tick(); _characterCreationMount?.Tick(); CharacterCreationController?.Tick();` — management always ticks first). The fixture now constructs management first, handing it a lazily-resolved closure over chargen's not-yet-existing `Controller.Open` — the SAME trick production's own `RetailUiRuntime.ConfigureCharacterManagement` uses (`bindings with { RequestCreate = () => CharacterCreationController?.Open() }`) — matching production's real construction AND tick order instead of contradicting it. The test also now asserts `Chargen.Controller.Root.ClickThrough == false` and a strictly higher `ZOrder` than management's root once both controllers have ticked with chargen open, pinning the `BringToFront` occlusion effect the reviewer had previously verified only by manual inspection. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum. **Corrected at the CC7 review-fix round, F6 (2026-08-16): the checksum half of that claim overstated what the assertion proves.** `Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum)` (`LiveSessionControllerCharacterCreationTests.cs:537`) is a round-trip/purity check — it computes the SAME production `CharacterCreate.ComputeChecksum` on both the encode and the decode side, not an independent golden value. It still closes the one gap (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 non-checksum fields were previously unverified); the checksum's actual golden value lives separately at `CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet` (the 19-term sum, golden `205u`), now cross-referenced from this test's own doc comment. `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). **Review fix round (this commit, F1-F9), CC7 REVIEW-CLOSED:** F1 files AP-229 for the screen-layering divergence the reviewer flagged (retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch; acdream keeps both `CharacterManagementUiController`/`CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes), records what the reviewer confirmed already works (selection/world-name persistence, click-through isolation, one coherent `Modal` stack), and the narrow residual risk it left open (the shared `RetailDialogFactory` can hand `UiRoot.Modal` to a dialog opened by the still-ticking, occluded management screen's `ReconcileDialogs` on an inbound `CharacterError` — a race retail cannot have since the occluded screen simply does not exist there). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe (ACE default 11, confirmed against `references/ACE/Source/ACE.Server/Command/Handlers/AdminCommands.cs:4393`) and the pending-delete-counts-too note. F3 adds AP-221's exact console-diagnostic lines to §CC2's known-gaps paragraph so a session-permanent dead preview reads as a known gap, not a fresh bug. F4 adds an empty-name/AP-227 step to §CC3 so the tester expects acdream's `NoNameWarning` dialog instead of retail's silent keep-old-name behavior. F5 adds an App-layer source-text pin (`GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter`) for the `CharacterCreated`/`CreationFailed` delegate wiring inside `LiveSessionRuntimeFactory.cs:229-236` the reviewer proved was deletable without breaking any test — no practical seam exists to construct the factory end-to-end without a `GameWindow` (confirmed: its one production construction site is deep inside `SessionPlayerComposition.cs`, and no test in the repo constructs it directly), so the pin follows this same test file's own established source-text pattern (`ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot`, `DisplacedLifecycleBodiesAreAbsent`) rather than a contrived full construction; the exact payload SHAPE these delegates produce was already pinned separately at `SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape`, so the new test plus that existing one together cover "bound" and "correct payload." F6/F7 correct this row's own wording above (checksum-assertion circularity; fixture construction order) and strengthen `CharacterScreensFixedCanvasArbiterTests` per F7's fix. F8 records a known flake found under full-solution parallel load on both reviewer runs (passes standalone, unrelated to CC7 — an allocation assertion sensitive to concurrent load): `AcDream.Runtime.Tests.Physics.RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, joining the existing Core.Net NakEmission / Content DecodedTextureCache / App SocialPanelLiveMountProbeTests known-flake set. F9 adds a one-line note to §CC2's Heritage-page Random step that a uniform pick over 13 heritages can repeat the current one. **Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate** (`docs/research/2026-08-16-campaign-cc-test-script.md`) — no automated live character creation has touched ACE yet; that gate remains the sole outstanding acceptance step. | | CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, `d2a71152`, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round `d2a71152` → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of `d2a71152` (R3) after re-deriving from the decomp: `gmCGAppearancePage::ListenToElementMessage`'s own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — `DoGradDisk` is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever `SetColor`/`SetSelection` run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; **the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of `d2a71152`, N2)** — none of the nine spins author Highlight-state media, so `RefreshColorAndShadeControls`'s `TrySetRetailState(Highlight)` call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. | diff --git a/docs/research/2026-08-16-campaign-cc-test-script.md b/docs/research/2026-08-16-campaign-cc-test-script.md index 14ca49aa..c9572d2c 100644 --- a/docs/research/2026-08-16-campaign-cc-test-script.md +++ b/docs/research/2026-08-16-campaign-cc-test-script.md @@ -71,6 +71,26 @@ and should be exercised at least once per gate. and does nothing when clicked. This is retail's own gate (`gmCharacterManagementUI::UpdateButtons`) — a full roster ghosts Create exactly like Enter/Delete grey out for an unselected row. + **A fresh test account is unlikely to be full on its own** (ACE's + default `max_chars_per_account` is 11) — force this state instead of + waiting for it: + 1. From the ACE server console (or a GM-privileged in-game `@` command), + run `@modifylong max_chars_per_account 2` to lower the ceiling below + your current roster count. + 2. Reconnect (a fresh `CharacterList` only arrives on a new connection — + the client does not re-fetch it live) and confirm Create is now + GREYED OUT. + 3. Restore the default afterward: `@modifylong max_chars_per_account 11`, + then reconnect again and confirm Create is enabled once more. + 4. **The count includes pending-delete (greyed) characters** — a + character mid-deletion still occupies a roster slot both in ACE's + `GameMessageCharacterList` and in acdream's own gate + (`RuntimeCharacterSelectionState.BuildButtons`'s + `_entries.Length < _slotCount`, which counts every roster entry + regardless of pending-delete state) — matching retail's own + `RebuildCharacterList`, which walks the same full set. If you have a + pending-delete character sitting around, it still counts toward the + ceiling above. ### Leaving the screen (Back-at-Heritage and Exit) @@ -121,7 +141,10 @@ bug — do not report it. uniformly-picked one of the 13 — this is AP-212's documented approximation (retail's own Heritage-page Random rolls with retail's own distribution; acdream picks uniformly over every installed heritage). - Not a bug to report unless the button does nothing or crashes. + A uniform pick over 13 can land back on the heritage you already have — + click a few times if the first click looks like a no-op; occasional + repeats are expected, not a bug. Not a bug to report unless the button + does nothing or crashes across several clicks. 3. Select **Olthoi** or **OlthoiAcid**. Confirm the Profession, Skills, and Town tabs are hidden (Olthoi variants skip straight to a fixed Custom template with no attribute/skill/town choices) and the screen @@ -194,6 +217,19 @@ other eight (AP-222 — this is a MEASURED gap in acdream's own art, not yet attributed to a specific missing asset; report clearly if you can visually compare with retail here). +**Known session-permanent gap (AP-221) — check the console before +reporting a dead preview.** On an unlucky frame where the DAT/GPU resource +read backing the 3D preview isn't ready at the client's single composition +pass, the Appearance page's zoom/rotate controls can go dead for the rest +of the session (or the Summary page's preview can simply never render), +with no on-screen error — the only evidence is a console line: +`[UI] chargen preview viewport unavailable at composition time...` (or the +Summary-page sibling, `[UI] summary preview viewport unavailable at +composition time...`). If zoom/rotate stop responding or a preview stays +blank, check the console for one of these lines FIRST. If it's there, +restart the client and retry before reporting a bug — this is a known, +already-registered gap, not a new one. + ### Town page 1. Confirm four town buttons: Holtburg, Shoushi, Yaraq, Sanamar (not id @@ -293,6 +329,23 @@ Already covered in §CC1's "Leaving the screen" section above. your typing at 32 characters as you go; the rejection only fires on commit. That is retail-correct, not a bug. +### Empty name (AP-227, an acdream/retail divergence — expected) + +1. On the Summary page, select all the text in the name field and delete + it entirely, then blur the field (click elsewhere) without typing a + replacement. +2. Click **Finish**. Confirm the `NoName`/`ID_CharGen_NoNameWarning` dialog + appears — acdream clears its internal name state the instant the field + is emptied, so Finish sees an empty name and refuses. **This is NOT what + retail does**: retail's own commit handler only acts when the field's + length is greater than 1 (NUL-inclusive, so an empty field's length is + exactly 1) — an emptied-then-blurred field is a silent no-op in retail, + and the character's internal name stays whatever it was BEFORE you + cleared the field, even though the field visually shows empty. A real + retail client would create the character under that old, uncleared name + here instead of showing a dialog. Expect acdream's dialog, not retail's + silent keep-old-name behavior — register AP-227. + ### What to report for §CC3 - Whether the happy path truly lands you in-world with no intermediate diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs index 309b26f3..a13977fc 100644 --- a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -135,6 +135,70 @@ public sealed class GameWindowLiveSessionOwnershipTests Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance)); } + /// + /// Campaign CC CC7 review-fix round, F5 (2026-08-16): the reviewer + /// found that deleting the CharacterCreated/CreationFailed + /// delegate assignments from + /// (the App-layer wiring that forwards those two Runtime events to + /// SessionStatusWriter, feeding the launcher's status-payload + /// cycle) leaves every test suite green. LiveSessionRuntimeFactory + /// has exactly one production construction site + /// (SessionPlayerComposition.cs), buried inside the full + /// GameWindow composition graph, and no test in this repository + /// constructs it directly — there is no practical seam to exercise the + /// wiring behaviorally without a . This test + /// follows the SAME source-text-pin pattern the rest of this file + /// already uses for wiring that can't otherwise be unit-tested + /// (, + /// ): it fails if either + /// delegate assignment is removed or its argument mapping changes. The + /// exact PAYLOAD shape these calls must produce is pinned separately, + /// at SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape + /// (tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs) + /// — together the two tests cover "the delegates are bound" (here) and + /// "they produce §LA1's exact payload" (there). + /// + [Fact] + public void LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter() + { + string root = FindRepositoryRoot(); + string source = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Net", + "LiveSessionRuntimeFactory.cs")); + + Assert.Contains( + "CharacterCreated: identity => _statusWriter.CharacterCreated(", + source, + StringComparison.Ordinal); + Assert.Contains( + "identity.Guid,", + source, + StringComparison.Ordinal); + Assert.Contains( + "identity.Name),", + source, + StringComparison.Ordinal); + Assert.Contains( + "CreationFailed: rejection => _statusWriter.CreationFailed(", + source, + StringComparison.Ordinal); + Assert.Contains( + "rejection.RawCode,", + source, + StringComparison.Ordinal); + Assert.Contains( + "rejection.Reason,", + source, + StringComparison.Ordinal); + Assert.Contains( + "rejection.AttemptedName)),", + source, + StringComparison.Ordinal); + } + private static int CountOccurrences(string source, string value) { int count = 0; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs index 764c87f9..9ea6c574 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs @@ -109,6 +109,16 @@ public sealed class CharacterScreensFixedCanvasArbiterTests // Character-management stays active/visible underneath -- chargen // opening on top never deactivates or hides it. Assert.True(environment.Management.Controller.Root.Visible); + // F7 (2026-08-16): pin the occlusion the reviewer verified only by + // hand. Chargen's root is click-opaque over the full authored + // canvas, so no input reaches management underneath it... + Assert.False(environment.Chargen.Controller.Root.ClickThrough); + // ...and BringToFront (called every tick chargen is open, see + // CharacterCreationUiController.Tick) keeps chargen's ZOrder + // strictly above management's, so it paints on top too. + Assert.True( + environment.Chargen.Controller.Root.ZOrder + > environment.Management.Controller.Root.ZOrder); environment.Chargen.Button(CharacterCreationUiController.ExitElementId) .OnClick!(); @@ -127,15 +137,26 @@ public sealed class CharacterScreensFixedCanvasArbiterTests public TwoControllerHarness() { Host = new UiRoot { Width = 800f, Height = 600f }; - // Campaign CC slice CC7: chargen must exist FIRST so - // ManagementHarness can wire its Create button straight to the - // real CharacterCreationUiController.Open() — the same shape - // RetailUiRuntime.ConfigureCharacterManagement() uses in - // production (a lazily-resolved lambda closing over the OTHER - // controller, since bindings are always built before both - // controllers exist). + // Campaign CC slice CC7 review-fix round, F7 (2026-08-16): + // construct in PRODUCTION order — management, then chargen — + // matching RetailUiRuntime.Tick's real sequence + // (`_characterManagementMount?.Tick(); ... + // _characterCreationMount?.Tick(); CharacterCreationController + // ?.Tick();`). An earlier version of this fixture built chargen + // FIRST and claimed that matched production; it did not — it + // was the opposite order. Management still needs a RequestCreate + // callback before Chargen's Controller exists, so this closure + // resolves Chargen lazily per-call, the SAME trick production's + // own RetailUiRuntime.ConfigureCharacterManagement uses + // (`bindings with { RequestCreate = () => + // CharacterCreationController?.Open() }`, closing over `this` + // rather than capturing a not-yet-built controller). + // Chargen is assigned below, before this closure can ever be + // invoked (Management's own ctor only stores the callback, it + // does not call it) -- the null-forgiving operator documents + // that ordering guarantee for the nullable analyzer. + Management = new ManagementHarness(Host, () => Chargen!.Controller.Open()); Chargen = new ChargenHarness(Host); - Management = new ManagementHarness(Host, Chargen.Controller.Open); } public UiRoot Host { get; } diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs index 0f07ee80..dad994e2 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs @@ -430,10 +430,22 @@ public sealed class LiveSessionControllerCharacterCreationTests /// test above never checked — against exactly the shape /// CharacterCreateInfo.Unpack/Appearance.Unpack parse (see /// 's own doc comment for the ACE - /// cross-reference). The checksum is recomputed via the SAME production - /// formula rather than - /// re-deriving the sum a second time by hand in the test. + /// cross-reference). /// + /// + /// Campaign CC CC7 review-fix round, F6 (2026-08-16): the checksum + /// assertion below (Assert.Equal(CharacterCreate.ComputeChecksum(r), + /// decoded.Checksum)) is a ROUND-TRIP/PURITY check, not an + /// independent golden — it recomputes the SAME production + /// formula the encode side + /// already used, rather than re-deriving the sum a second time by hand, + /// so it proves the wire-encode/decode round trip is lossless but + /// cannot by itself catch a bug shared by both the encoder and this + /// formula. The checksum's actual golden value (the 19-term retail + /// accumulation set, hand-summed to 205u) is pinned separately + /// at CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet + /// (tests/AcDream.Core.Net.Tests/Messages/CharacterCreateTests.cs). + /// [Fact] public void Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape() { From 84d0bbd14c5c9c1a614f87c656bc29bbed2f9cbb Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 03:14:43 +0200 Subject: [PATCH 108/138] =?UTF-8?q?docs:=20Campaign=20CC=20closeout=20?= =?UTF-8?q?=E2=80=94=20CLAUDE.md=20Current=20state=20entry=20+=20CC7=20led?= =?UTF-8?q?ger=20sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE. The sole outstanding acceptance step is the user's connected gate (docs/research/2026-08-16-campaign-cc-test-script.md). Fills the CC7 ledger row's literal fix-round sha (2176ba76) per the established follow-up-docs-commit pattern, and adds the Campaign CC block to CLAUDE.md Current state (the CC7 review's F10 observation, deferred to this lead closeout commit). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 26 +++++++++++++++++++ .../2026-08-15-character-creation-campaign.md | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9baa7773..b42b3fff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -256,6 +256,32 @@ strict status/redaction evidence, and the exact Windows/Ubuntu operator script are landed; the integrated preflight passes 32/32 commands and 14,012 tests / 5 skips. Only the connected/visual/real-DAT user gate remains before shipment. +**Campaign CC — retail character creation (CODE-COMPLETE 2026-08-16, all +seven slices REVIEW-CLOSED; the user's connected gate is the sole +outstanding acceptance step).** The full retail creation flow: Create +button (retail's exact `UpdateButtons` roster` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | -| CC7 | REVIEW-CLOSED 2026-08-16 | `9cf6c522`, `ddcbf1fb`, plus the F1-F9 review-fix round (this commit — its own literal sha is not self-referenceable within one commit; append via a tiny follow-up docs commit per the established `bb22ee8b`-style pattern if the literal hash is needed) | CLOSED (dual-lens: both lenses PASS-with-items → F1-F9 fix round this commit; lead diff-check close per the doc/test-only residual pattern) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. **Corrected at the CC7 review-fix round, F7 (2026-08-16): the original fixture-ordering claim above was WRONG.** The shared fixture originally constructed chargen FIRST so its `Open` method existed to wire into management's `RequestCreate` binding — the OPPOSITE of production's real tick order (`RetailUiRuntime.Tick`: `_characterManagementMount?.Tick(); CharacterManagementController?.Tick(); _characterCreationMount?.Tick(); CharacterCreationController?.Tick();` — management always ticks first). The fixture now constructs management first, handing it a lazily-resolved closure over chargen's not-yet-existing `Controller.Open` — the SAME trick production's own `RetailUiRuntime.ConfigureCharacterManagement` uses (`bindings with { RequestCreate = () => CharacterCreationController?.Open() }`) — matching production's real construction AND tick order instead of contradicting it. The test also now asserts `Chargen.Controller.Root.ClickThrough == false` and a strictly higher `ZOrder` than management's root once both controllers have ticked with chargen open, pinning the `BringToFront` occlusion effect the reviewer had previously verified only by manual inspection. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum. **Corrected at the CC7 review-fix round, F6 (2026-08-16): the checksum half of that claim overstated what the assertion proves.** `Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum)` (`LiveSessionControllerCharacterCreationTests.cs:537`) is a round-trip/purity check — it computes the SAME production `CharacterCreate.ComputeChecksum` on both the encode and the decode side, not an independent golden value. It still closes the one gap (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 non-checksum fields were previously unverified); the checksum's actual golden value lives separately at `CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet` (the 19-term sum, golden `205u`), now cross-referenced from this test's own doc comment. `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). **Review fix round (this commit, F1-F9), CC7 REVIEW-CLOSED:** F1 files AP-229 for the screen-layering divergence the reviewer flagged (retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch; acdream keeps both `CharacterManagementUiController`/`CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes), records what the reviewer confirmed already works (selection/world-name persistence, click-through isolation, one coherent `Modal` stack), and the narrow residual risk it left open (the shared `RetailDialogFactory` can hand `UiRoot.Modal` to a dialog opened by the still-ticking, occluded management screen's `ReconcileDialogs` on an inbound `CharacterError` — a race retail cannot have since the occluded screen simply does not exist there). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe (ACE default 11, confirmed against `references/ACE/Source/ACE.Server/Command/Handlers/AdminCommands.cs:4393`) and the pending-delete-counts-too note. F3 adds AP-221's exact console-diagnostic lines to §CC2's known-gaps paragraph so a session-permanent dead preview reads as a known gap, not a fresh bug. F4 adds an empty-name/AP-227 step to §CC3 so the tester expects acdream's `NoNameWarning` dialog instead of retail's silent keep-old-name behavior. F5 adds an App-layer source-text pin (`GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter`) for the `CharacterCreated`/`CreationFailed` delegate wiring inside `LiveSessionRuntimeFactory.cs:229-236` the reviewer proved was deletable without breaking any test — no practical seam exists to construct the factory end-to-end without a `GameWindow` (confirmed: its one production construction site is deep inside `SessionPlayerComposition.cs`, and no test in the repo constructs it directly), so the pin follows this same test file's own established source-text pattern (`ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot`, `DisplacedLifecycleBodiesAreAbsent`) rather than a contrived full construction; the exact payload SHAPE these delegates produce was already pinned separately at `SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape`, so the new test plus that existing one together cover "bound" and "correct payload." F6/F7 correct this row's own wording above (checksum-assertion circularity; fixture construction order) and strengthen `CharacterScreensFixedCanvasArbiterTests` per F7's fix. F8 records a known flake found under full-solution parallel load on both reviewer runs (passes standalone, unrelated to CC7 — an allocation assertion sensitive to concurrent load): `AcDream.Runtime.Tests.Physics.RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, joining the existing Core.Net NakEmission / Content DecodedTextureCache / App SocialPanelLiveMountProbeTests known-flake set. F9 adds a one-line note to §CC2's Heritage-page Random step that a uniform pick over 13 heritages can repeat the current one. **Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate** (`docs/research/2026-08-16-campaign-cc-test-script.md`) — no automated live character creation has touched ACE yet; that gate remains the sole outstanding acceptance step. | +| CC7 | REVIEW-CLOSED 2026-08-16 | `9cf6c522`, `ddcbf1fb`, F1-F9 review-fix round `2176ba76` | CLOSED (dual-lens: both lenses PASS-with-items → F1-F9 fix round this commit; lead diff-check close per the doc/test-only residual pattern) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. **Corrected at the CC7 review-fix round, F7 (2026-08-16): the original fixture-ordering claim above was WRONG.** The shared fixture originally constructed chargen FIRST so its `Open` method existed to wire into management's `RequestCreate` binding — the OPPOSITE of production's real tick order (`RetailUiRuntime.Tick`: `_characterManagementMount?.Tick(); CharacterManagementController?.Tick(); _characterCreationMount?.Tick(); CharacterCreationController?.Tick();` — management always ticks first). The fixture now constructs management first, handing it a lazily-resolved closure over chargen's not-yet-existing `Controller.Open` — the SAME trick production's own `RetailUiRuntime.ConfigureCharacterManagement` uses (`bindings with { RequestCreate = () => CharacterCreationController?.Open() }`) — matching production's real construction AND tick order instead of contradicting it. The test also now asserts `Chargen.Controller.Root.ClickThrough == false` and a strictly higher `ZOrder` than management's root once both controllers have ticked with chargen open, pinning the `BringToFront` occlusion effect the reviewer had previously verified only by manual inspection. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum. **Corrected at the CC7 review-fix round, F6 (2026-08-16): the checksum half of that claim overstated what the assertion proves.** `Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum)` (`LiveSessionControllerCharacterCreationTests.cs:537`) is a round-trip/purity check — it computes the SAME production `CharacterCreate.ComputeChecksum` on both the encode and the decode side, not an independent golden value. It still closes the one gap (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 non-checksum fields were previously unverified); the checksum's actual golden value lives separately at `CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet` (the 19-term sum, golden `205u`), now cross-referenced from this test's own doc comment. `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). **Review fix round (this commit, F1-F9), CC7 REVIEW-CLOSED:** F1 files AP-229 for the screen-layering divergence the reviewer flagged (retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch; acdream keeps both `CharacterManagementUiController`/`CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes), records what the reviewer confirmed already works (selection/world-name persistence, click-through isolation, one coherent `Modal` stack), and the narrow residual risk it left open (the shared `RetailDialogFactory` can hand `UiRoot.Modal` to a dialog opened by the still-ticking, occluded management screen's `ReconcileDialogs` on an inbound `CharacterError` — a race retail cannot have since the occluded screen simply does not exist there). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe (ACE default 11, confirmed against `references/ACE/Source/ACE.Server/Command/Handlers/AdminCommands.cs:4393`) and the pending-delete-counts-too note. F3 adds AP-221's exact console-diagnostic lines to §CC2's known-gaps paragraph so a session-permanent dead preview reads as a known gap, not a fresh bug. F4 adds an empty-name/AP-227 step to §CC3 so the tester expects acdream's `NoNameWarning` dialog instead of retail's silent keep-old-name behavior. F5 adds an App-layer source-text pin (`GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter`) for the `CharacterCreated`/`CreationFailed` delegate wiring inside `LiveSessionRuntimeFactory.cs:229-236` the reviewer proved was deletable without breaking any test — no practical seam exists to construct the factory end-to-end without a `GameWindow` (confirmed: its one production construction site is deep inside `SessionPlayerComposition.cs`, and no test in the repo constructs it directly), so the pin follows this same test file's own established source-text pattern (`ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot`, `DisplacedLifecycleBodiesAreAbsent`) rather than a contrived full construction; the exact payload SHAPE these delegates produce was already pinned separately at `SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape`, so the new test plus that existing one together cover "bound" and "correct payload." F6/F7 correct this row's own wording above (checksum-assertion circularity; fixture construction order) and strengthen `CharacterScreensFixedCanvasArbiterTests` per F7's fix. F8 records a known flake found under full-solution parallel load on both reviewer runs (passes standalone, unrelated to CC7 — an allocation assertion sensitive to concurrent load): `AcDream.Runtime.Tests.Physics.RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, joining the existing Core.Net NakEmission / Content DecodedTextureCache / App SocialPanelLiveMountProbeTests known-flake set. F9 adds a one-line note to §CC2's Heritage-page Random step that a uniform pick over 13 heritages can repeat the current one. **Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate** (`docs/research/2026-08-16-campaign-cc-test-script.md`) — no automated live character creation has touched ACE yet; that gate remains the sole outstanding acceptance step. | | CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, `d2a71152`, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round `d2a71152` → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of `d2a71152` (R3) after re-deriving from the decomp: `gmCGAppearancePage::ListenToElementMessage`'s own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — `DoGradDisk` is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever `SetColor`/`SetSelection` run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; **the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of `d2a71152`, N2)** — none of the nine spins author Highlight-state media, so `RefreshColorAndShadeControls`'s `TrySetRetailState(Highlight)` call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. | From 344d88bff791feb4bd28e20c1e3841a079e76baf Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 08:38:07 +0200 Subject: [PATCH 109/138] =?UTF-8?q?fix=20#405:=20chargen/summary=20preview?= =?UTF-8?q?=20leases=20never=20Transferred=20=E2=80=94=20every=20retail-UI?= =?UTF-8?q?=20window=20load=20crashed=20at=20composition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CC gate round 1, first finding. CC6b-MOUNT's chargen preview lease and CC5's summary preview lease both rode into the published live presentation beside the paperdoll/appraisal siblings but never got their Transfer() calls in CompletePresentation's ladder, so the composition scope's unpublished-resource leak guard threw on every real window load with retail UI mounted (launcher path and dev path alike) and the client died before connecting. Two-line fix at the ladder; verified by a live launch reaching started/connected/characterList with a graceful close. Also files #406: the launcher recorded this crash as exited{code:0, reason:graceful} — the session orchestrator's exit observation is wrong and misled the first diagnosis; the console repro showed the real 0xE0434352. No automated suite executes the transfer ladder (needs a live GPU window) — the coverage gap is recorded in #405's closing note. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 45 +++++++++++++++++++ .../LivePresentationComposition.cs | 11 +++++ 2 files changed, 56 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 767a3f9e..9ae3ed27 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,51 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #406 — Launcher records a crashed client as `exited{code:0,reason:"graceful"}` + +**Status:** OPEN (Campaign CC gate round 1, 2026-08-16) +**Severity:** MEDIUM (diagnosis-misleading, not data-loss) + +Found while diagnosing #405: the client process died with exit code +`0xE0434352` (.NET unhandled exception, stack on stderr), but the +launcher's session status stream recorded `{"e":"exited","code":0, +"reason":"graceful"}` — the exact opposite of what happened. Running the +identical binary + session config from a console shows the true nonzero +exit code, so the corruption is in the launcher's session-orchestrator +exit observation (wrong process handle/exit-code read, or a default that +masks the real code), not in the client. §LA1 explicitly promises +`exited{code,reason}` carries the real termination; a launcher that +reports "graceful" for a crash sends any future gate/automation +diagnosis in the wrong direction (it did exactly that this round until +the console repro). Investigate the launcher-side session orchestrator's +exit capture; a test should pin a nonzero-exit child producing +`exited{code:,reason:"crashed"|"failed"}` per the LA contract's +vocabulary. + +## #405 — CLOSED: chargen/summary preview leases missing Transfer killed every retail-UI window load + +**Status:** DONE (`fix #405` commit, 2026-08-16 — Campaign CC gate round 1) +**Severity:** CRITICAL (client unusable via launcher/retail-UI path) + +`LivePresentationCompositionPhase.CompletePresentation`'s lease-transfer +ladder never gained `chargenPreviewLease?.Transfer()` (CC6b-MOUNT) nor +`summaryPreviewLease?.Transfer()` (CC5, faithfully duplicating the same +miss). Both resources rode into the published result beside the +paperdoll/appraisal siblings, but `CompositionAcquisitionScope.Complete()` +saw two acquired-unpublished leases and threw +`InvalidOperationException: Composition phase completed with unpublished +resources: chargen preview viewport, summary preview viewport` on EVERY +real window load with retail UI mounted — the client died ~1.7 s after +start, before connecting. Five review rounds read past it because no +automated suite executes the transfer ladder (it needs a live GPU +window; `LivePresentationCompositionTests` covers scope mechanics only) +and no graphical launch happened between CC6b-MOUNT's landing and the +user's gate. Follow-up test-coverage gap: a composition-level fake-GPU +harness that drives `ComposeCore` through `scope.Complete()` would have +caught this and remains unbuilt — weigh it against the E6 deterministic +suite patterns before CC's campaign close. Verified fixed by a live +launch: `started → connected → characterList`, graceful close. + ## #404 — ChargenSkillScoreResolver duplicates ChargenTableReader's own SkillTable read **Status:** OPEN (post-CC cleanup follow-up) diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 456baf4b..a4b66ff8 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1535,6 +1535,17 @@ internal sealed class LivePresentationCompositionPhase retainedGameplayLease?.Transfer(); paperdollLease?.Transfer(); creatureAppraisalLease?.Transfer(); + // #405: these two Transfer calls were MISSING from CC6b-MOUNT (chargen) + // and CC5 (summary) — both leases rode into the published result at + // the paperdoll siblings' positions above, but without the Transfer + // the scope's unpublished-resource leak guard threw on every real + // window load ("Composition phase completed with unpublished + // resources: chargen preview viewport, summary preview viewport"), + // killing the client at startup whenever retail UI mounted the + // chargen screen. No automated suite executes this ladder (it needs + // a live GPU window), which is how five review rounds read past it. + chargenPreviewLease?.Transfer(); + summaryPreviewLease?.Transfer(); envCellLease.Transfer(); clipFrameLease.Transfer(); portalDepthLease.Transfer(); From 26e6f984f481e53221bc999cdb1af6bcfa22bc5d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 08:58:51 +0200 Subject: [PATCH 110/138] =?UTF-8?q?docs:=20file=20#407=20=E2=80=94=20windo?= =?UTF-8?q?wed=20resolution=20offering=20starves=20on=20RDP=20(video-mode?= =?UTF-8?q?=20gating)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CC gate round 1, second finding. The RDP virtual display advertises exactly two video modes (1920x1080 + the 2056x1290 desktop), so #391's curated catalog — correct for fullscreen mode switches — leaves the WINDOWED size dropdown with nothing below 1920. Windowed sizes need no video mode; the fix splits the offering by target state (union list for the dropdown, hardware-gated validation only for the fullscreen apply). Fix lands with this gate round's batch; live workaround confirmed: drag-resize. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9ae3ed27..34447de5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,38 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating) + +**Status:** OPEN (Campaign CC gate round 1, 2026-08-16) +**Severity:** MEDIUM (windowed usability on remote/virtual displays) + +Found live during the CC gate over RDP: the Config Resolution dropdown +offered exactly two entries — `1920x1080` and the desktop's own +`2056x1290` — because the RDP virtual display's driver advertises only +those two video modes (measured via `EnumDisplaySettings`: the physical +2560x1440 monitor's mode list is not visible to the remote session at +all; the two secondary virtual displays expose only `800x600`). +`DisplayModeCatalog` (#391) honestly curates what the monitor +enumerates — the defect is the DESIGN conflation: the WINDOWED size +offering is gated on fullscreen-capable video modes, but a windowed +client needs no video mode — any size that fits the desktop is +displayable. On a physical monitor the conflation is invisible (rich +mode list); on RDP it collapses to nothing below 1920. + +Fix direction: split the offering by target state. The dropdown offers +(static modern ladder entries that fit the desktop) ∪ (curated hardware +modes), ascending; the windowed apply (a plain Size write) accepts any +offered entry ≤ desktop; the fullscreen apply keeps the hardware-catalog +validation + `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard +UNCHANGED (a fullscreen pick of a non-hardware mode refuses safely, +log-and-stay per #388 — the #392 apply-result seam is that family's +existing follow-up). #391's "an offered mode is by construction a +supported one" invariant narrows to the fullscreen half and must be +re-documented; register IA-22 (user-directed curation) gets the same +amendment. Immediate workaround (confirmed live): drag-resize the +windowed client — resize events rebuild the swapchain (#387) and the +retail UI rescales from its 800x600 authored canvas. + ## #406 — Launcher records a crashed client as `exited{code:0,reason:"graceful"}` **Status:** OPEN (Campaign CC gate round 1, 2026-08-16) From e601a496dba278050d9b448f4823a608b35d4469 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 09:03:36 +0200 Subject: [PATCH 111/138] fix #407: windowed resolution offering decoupled from the video-mode list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CC gate round 1. The Config Resolution dropdown now offers DisplayModeCatalog.WindowedResolutions — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a windowed pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost none (the live RDP display exposed exactly 1920x1080 + the 2056x1290 desktop, leaving the dropdown with nothing below 1920). The fullscreen apply still validates against the hardware Resolutions list plus the switcher's monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388/#392) — IA-22's offered-implies-supported invariant narrows to the fullscreen half and its register row carries the amendment. Three new pure-union tests including the exact live RDP shape. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../Rendering/DisplayModeCatalog.cs | 73 ++++++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 7 +- .../Rendering/DisplayModeCatalogTests.cs | 42 +++++++++++ 4 files changed, 119 insertions(+), 5 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 31e42d69..ffebb196 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -59,7 +59,7 @@ accepted-divergence entries (#96, #49, #50). | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`). This row is auto-acquisition-only: as of #298, explicit-target admission and the combat camera route through the separate, retail-exact `WorldSelectionQuery.IsAttackableTarget` (`ObjectIsAttackable`-backed) instead, so a compatible-PK player is a valid manual attack/camera target — do not assume one predicate still serves both concerns. | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | | IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` | -| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction; the curated list is also the fullscreen mode-switch validation source (#376/#388), so an offered mode is supported by construction — "Graphics mode not supported" crashes become unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | +| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | --- diff --git a/src/AcDream.App/Rendering/DisplayModeCatalog.cs b/src/AcDream.App/Rendering/DisplayModeCatalog.cs index 71f48f40..986b8fb0 100644 --- a/src/AcDream.App/Rendering/DisplayModeCatalog.cs +++ b/src/AcDream.App/Rendering/DisplayModeCatalog.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AcDream.UI.Abstractions.Panels.Settings; using Silk.NET.Windowing; namespace AcDream.App.Rendering; @@ -29,13 +30,28 @@ namespace AcDream.App.Rendering; internal static class DisplayModeCatalog { private static IReadOnlyList? _resolutions; + private static IReadOnlyList? _windowedResolutions; private static string? _desktopResolution; - /// The curated list, or null when no catalog was installed - /// (fixture/headless callers — consumers fall back to the static - /// preset ladder). + /// The curated HARDWARE mode list, or null when no catalog was + /// installed (fixture/headless callers — consumers fall back to the + /// static preset ladder). This is the fullscreen mode-switch validation + /// source (#376/#388): a fullscreen pick must be a real adapter mode. public static IReadOnlyList? Resolutions => _resolutions; + /// #407: the WINDOWED size offering — the curated hardware + /// modes UNIONed with the static modern ladder entries that fit the + /// desktop. A windowed client needs no video mode (a Size write is + /// displayable at any size ≤ desktop), so gating the windowed dropdown + /// on the adapter's mode list starved remote/virtual displays whose + /// drivers advertise almost nothing (the RDP display that exposed only + /// 1920x1080 + the desktop mode, found live at the Campaign CC gate). + /// Null when no catalog was installed. The fullscreen APPLY still + /// validates against + the switcher's own + /// monitor-mode-list hard guard, so a fullscreen pick of a + /// windowed-only entry refuses safely (log-and-stay, #388/#392). + public static IReadOnlyList? WindowedResolutions => _windowedResolutions; + /// The desktop's current mode as a "WxH" string — the Config /// Resolution row's Defaults value in production (see the class doc for /// why this replaces retail's authored 800x600). Null when no catalog @@ -69,6 +85,7 @@ internal static class DisplayModeCatalog return; _resolutions = curated; + _windowedResolutions = BuildWindowedOffering(curated, (desktop.X, desktop.Y)); _desktopResolution = $"{desktop.X}x{desktop.Y}"; } @@ -76,9 +93,59 @@ internal static class DisplayModeCatalog internal static void ResetForTests() { _resolutions = null; + _windowedResolutions = null; _desktopResolution = null; } + /// + /// #407's pure union rule: the windowed offering is every curated + /// hardware mode plus every static-ladder entry that fits the desktop, + /// deduped, ascending by width then height — the same ordering + /// emits so the dropdown reads identically on + /// physical and remote displays. + /// + internal static IReadOnlyList BuildWindowedOffering( + IReadOnlyList curated, + (int W, int H) desktop) + { + var keep = new SortedSet<(int W, int H)>( + Comparer<(int W, int H)>.Create(static (a, b) => + a.W != b.W ? a.W.CompareTo(b.W) : a.H.CompareTo(b.H))); + + foreach (string spec in curated) + { + if (TryParse(spec, out (int W, int H) mode)) + keep.Add(mode); + } + foreach (string spec in DisplaySettings.AvailableResolutions) + { + if (TryParse(spec, out (int W, int H) mode) + && mode.W <= desktop.W + && mode.H <= desktop.H) + { + keep.Add(mode); + } + } + + return keep.Select(static m => $"{m.W}x{m.H}").ToArray(); + + static bool TryParse(string spec, out (int W, int H) mode) + { + mode = default; + string[] parts = spec.Split('x', 2); + if (parts.Length == 2 + && int.TryParse(parts[0], out int w) + && int.TryParse(parts[1], out int h) + && w > 0 + && h > 0) + { + mode = (w, h); + return true; + } + return false; + } + } + /// /// The pure curation rule (#391): keep a mode iff /// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 / diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 07381f1d..a62fb663 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2578,7 +2578,12 @@ public sealed class RetailUiRuntime : IDisposable // default, installed at startup by the graphical host; // fixture/headless mounts leave the catalog empty and the // controller falls back to the static preset ladder. - availableResolutions: Rendering.DisplayModeCatalog.Resolutions, + // #407: the dropdown offers the WINDOWED union (hardware + // modes + static-ladder sizes that fit the desktop) — a + // windowed Size write needs no video mode, and remote/RDP + // displays advertise almost none. The fullscreen APPLY + // still validates against the hardware list only. + availableResolutions: Rendering.DisplayModeCatalog.WindowedResolutions, resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution); if (!configBound) Console.WriteLine("[UI] options panel: Config tab rows did not bind."); diff --git a/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs b/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs index 623d2ce4..954bab9b 100644 --- a/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DisplayModeCatalogTests.cs @@ -105,4 +105,46 @@ public sealed class DisplayModeCatalogTests // be re-selectable from the offered list. Assert.Contains(DisplaySettings.Default.Resolution, DisplaySettings.AvailableResolutions); } + + // ── #407: the windowed offering union (Campaign CC gate round 1) ──── + + [Fact] + public void BuildWindowedOffering_RdpStarvedModeList_GainsTheLadderSizesThatFit() + { + // The live RDP shape that motivated #407: the virtual display + // advertised exactly two modes (1920x1080 + the 2056x1290 desktop), + // so the hardware-gated dropdown offered nothing below 1920. The + // windowed union restores every static-ladder size that fits. + var offering = DisplayModeCatalog.BuildWindowedOffering( + ["1920x1080", "2056x1290"], (2056, 1290)); + + Assert.Equal( + ["1280x720", "1366x768", "1600x900", "1920x1080", "2056x1290"], + offering); + } + + [Fact] + public void BuildWindowedOffering_RichMonitor_IsTheDedupedUnion() + { + // On a physical monitor the hardware list already contains the + // ladder sizes — the union adds nothing and stays ascending/deduped. + var offering = DisplayModeCatalog.BuildWindowedOffering( + ["1280x720", "1366x768", "1600x900", "1920x1080", "1920x1200", "2560x1440"], + Desktop2560); + + Assert.Equal( + ["1280x720", "1366x768", "1600x900", "1920x1080", "1920x1200", "2560x1440"], + offering); + } + + [Fact] + public void BuildWindowedOffering_LadderEntriesLargerThanTheDesktop_StayExcluded() + { + // A 1600x900 desktop admits only the ladder sizes that fit; the + // desktop's own (hardware-curated) mode always survives the union. + var offering = DisplayModeCatalog.BuildWindowedOffering( + ["1600x900"], (1600, 900)); + + Assert.Equal(["1280x720", "1366x768", "1600x900"], offering); + } } From b9557a322d552717de61598f5ef45f44d4b7d1ca Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 09:03:51 +0200 Subject: [PATCH 112/138] docs: #407 status DONE at e601a496 Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 34447de5..1c50ec6c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,7 +26,7 @@ What does NOT go here: ## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating) -**Status:** OPEN (Campaign CC gate round 1, 2026-08-16) +**Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix) **Severity:** MEDIUM (windowed usability on remote/virtual displays) Found live during the CC gate over RDP: the Config Resolution dropdown From 6699e0f88c669b655b4c25ee112b35bdadcfc588 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 09:27:58 +0200 Subject: [PATCH 113/138] =?UTF-8?q?docs:=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20=E2=80=94=20the=20six-page=20findings=20batch=20GF-1..GF-16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...-08-16-campaign-cc-gate-round1-findings.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/research/2026-08-16-campaign-cc-gate-round1-findings.md diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md new file mode 100644 index 00000000..18a3184f --- /dev/null +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -0,0 +1,82 @@ +# Campaign CC connected gate — round 1 findings (2026-08-16) + +User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, +windowed). Screenshots: retail Heritage, acdream Heritage, retail +Profession. The user's side-by-side retail reports are AXIOMS +(`feedback_retail_oracle_no_whack_a_mole`). Pre-page findings #405 (fixed +`344d88bf`), #406 (open), #407 (fixed `e601a496`) are recorded in +ISSUES.md; this doc is the six-page batch. + +## Functional (blocking or behavior-dead) + +- **GF-1 Heritage selection dead/unmarked.** Clicking a heritage row does + not light its radio dot (retail: orange lit dot on the selected row — + screenshot 1). Unclear whether the click dispatches at all (the + description text that would confirm is itself broken, GF-2). ALSO: the + open-roll's own rolled heritage shows NO lit dot on entry — every dot + dark in the acdream screenshot. +- **GF-5 Skills page empty.** Nothing renders except the screen-description + textbox — no skill rows, no credits display. (CC5's residual round wired + `TemplateResolver` into the SKILLS page too — yet live rows are absent.) +- **GF-9 Appearance color swatches do nothing observable.** Clicking a + color produces no visible change (model recolor absent). Could be a dead + dispatch or could be working-but-invisible (AP-216 authored-art swatches + + a recolor that fails); investigate, don't guess. +- **GF-11a Town description text does not change** when switching towns. +- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name** — an + acdream-only text leak; retail's summary list has no such rows. +- **GF-15 Summary name entry DEAD + Finish unpressable.** Cannot type into + the name field at all; Finish cannot be pressed. Also the field is not + prefilled with retail's `[ Name` placeholder. This blocks the entire + create flow — the gate cannot proceed past Summary. + +## Presentation families (retail parity) + +- **GF-2 Description textboxes broken everywhere.** acdream renders the + raw string with LITERAL `\n` escapes, one truncated line, no wrap, no + scroll, no frame. Retail: framed scrollable textbox, multi-paragraph, + colored section headers (green "Trained Starting Skills:" etc.), + scrollbar + arrows (screenshot 1 right panel). +- **GF-3 Profession template description textbox missing** (retail bottom- + left panel, "LIFE CASTERS are experts…" — screenshot 3). +- **GF-4 Profession labels missing:** "Attribute Credits" caption + value, + per-attribute name labels (Strength…Self), Health/Stamina/Mana labels + + values. Sliders and template selection themselves WORK. +- **GF-6 Appearance spin captions are numbers,** not part names + ("Hair Style", "Eyes", …). Known rows AP-215/AP-218 — the gate promotes + them to must-port. +- **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14); + retail's chargen 3D view shows a scenic backdrop. (The Heritage-page + preview area shows terrain in BOTH clients — establish from the decomp + what actually renders behind the model per page/view.) +- **GF-8 Appearance Face/Clothes sub-tab selection unmarked** (AP-222 + family, promoted by the gate). +- **GF-10 Zoom buttons show identical art** whichever is pushed. +- **GF-11b Town selected marker does not turn white** (button highlights, + but retail's selected-town graphic swaps to white). +- **GF-11c Town names misaligned on the map** vs retail. +- **GF-12 Missing authored gold frames** around boxes on every page + (Skills/Appearance/Town/Summary called out explicitly). +- **GF-14 Summary paperdoll backdrop black** (same family as GF-7); + Summary textbox wrapper + scrollbar missing (GF-2 family). +- **GF-16 Hover tooltips missing on all pages** (retail pops tooltips). + +## Suspected shared roots (to be CONFIRMED by the investigation, not assumed) + +1. Missing frames/labels/statics across every page (GF-3, GF-4 labels, + GF-12) — one importer/mount-level gap OR retail writes them at runtime; + decide per element from the authored DAT + decomp. +2. Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap + feeding GF-2/GF-3/GF-11a/GF-14. +3. Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, + GF-10 zoom art) — the AP-222 measured mechanism (state media authored + vs applied) across widget kinds. +4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws. +5. Input routing on Summary (GF-15) — focus/typing path on the stacked + chargen screen. + +## Process + +Root-cause investigation FIRST (report-only, static + live-DAT probe +tests, NO client launches while the user's client may be running), then +batched fix rounds per family with Opus review, one republish per batch. From 1d9de5e09588f582a619097b78773558c7de9f7e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 10:54:41 +0200 Subject: [PATCH 114/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20A=20=E2=80=94=20GF-15=20input,=20GF-5=20ski?= =?UTF-8?q?lls=20rows,=20GF-13=20GM=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GF-15 (the gate blocker): the Summary name field and Finish button were NOT structurally broken — live repro over the project's own local ACE test server showed clicks correctly focus the field and land characters. The real bug only surfaces after the first dialog opens: pressing Finish empty successfully creates the NoName RetailMessageDialogView (visible, correct 400x95 geometry) but it renders nothing and silently absorbs every click across the whole canvas. Root cause: CharacterCreationUiController.Tick and CharacterManagementUiController.Tick both call UiRoot.BringToFront(Root) unconditionally every frame (needed so chargen stays above the occluded management screen, AP-229); a dialog root is a direct sibling under the same UiRoot, and RetailWindowManager.BringToFront is "highest ZOrder among siblings + 1" — whichever BringToFront runs last in a frame wins. RetailDialogFactory.Tick never re-asserted its own dialogs' z-order, so the next frame's screen Tick buried the dialog behind the screen's opaque backdrop while it stayed the registered Modal with exclusive input priority. Fixed by having RetailDialogFactory.Tick re-raise every open dialog (in open-order) each tick, matching retail's always-on-top dialog behavior. Live-verified the complete user sequence end to end: click field, type, press Finish empty, dialog now visibly renders, OK dismisses cleanly, field still typable afterward. The "[ Name" prefill question is closed as a non-bug: neither CharGenState::RandomizeCharacter nor gmCGSummaryPage::InitializePage write text into the field in the decomp; retail's field is genuinely empty on open, matching acdream already. GF-5: CharacterCreationSkillsPage.RebuildRows resolved the wrong listbox template (Templates[0], retail's own 3-child bucket-header row) and required the root to be a UiButton (it's a plain container). Byte-traced gmCGSkillsPage::DoSkillRecords + tagSkillRecord's copy-ctor field order to map every child id in the real row (Templates[1]): name, level/cost text, and the two real per-row up/down arrow buttons. Wired the arrows to retail's own plain-click dispatch, retiring (narrowing) AP-213's click-to-advance/double-click-retreat single-button substitution. GF-13: dat property 0x3B (Invisible) was never read by the importer. Elements 0x10000403/0x10000494 ("Non-Admin"/"Non-Envoy") author it true. A blast-radius sweep found 1,083 elements client-wide author the same flag, so this fix stays chargen-scoped only (ElementInfo.Invisible / UiElement.AuthoredInvisible are pure data additions; only CharacterCreationUiController acts on them, by the authored flag, not a hardcoded id list). General importer-wide honor filed as ISSUES.md #408; register row AP-230 records the split. Gates: solution build green; App 5266/3 skips/0 failed; Runtime 1735/0; full-solution run 0 failures anywhere. Register: AP-230 filed, AP-213 narrowed. ISSUES: #408 filed. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 47 ++++ .../retail-divergence-register.md | 5 +- ...-08-16-campaign-cc-gate-round1-findings.md | 96 ++++++- .../UI/Layout/CharacterCreationSkillsPage.cs | 196 ++++++++++---- .../Layout/CharacterCreationUiController.cs | 35 +++ src/AcDream.App/UI/Layout/ElementReader.cs | 29 ++ src/AcDream.App/UI/Layout/LayoutImporter.cs | 4 + .../UI/Layout/RetailDialogFactory.cs | 44 +++- src/AcDream.App/UI/UiElement.cs | 13 + .../Layout/CharacterCreationLiveDatTests.cs | 183 +++++++++++++ .../CharacterCreationUiControllerTests.cs | 249 ++++++++++++++++-- 11 files changed, 823 insertions(+), 78 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1c50ec6c..307ebde0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,53 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide) + +**Status:** OPEN +**Severity:** LOW-MEDIUM (cosmetic — extra/leaked elements render where retail hides them; no gameplay/wire impact) + +Found while fixing GF-13 (Campaign CC gate round 1, Batch A, 2026-08-16): +acdream's `LayoutImporter`/`DatWidgetFactory` never read dat property +`0x3B` (Invisible — `BoolBaseProperty`), which retail's +`UIElement::OnSetAttribute @0x00462d80` case 8 +(`GetPropertyName()-0x33==8`) honors on EVERY element via +`SetVisible(value==0)`. The blast-radius sweep this fix's investigation +ran found **1,083 elements client-wide** author `P0x3B=true` — far +beyond the two chargen-Summary GM labels (`0x10000403` +"Non-Admin"/`0x10000494` "Non-Envoy") the user actually reported. + +The fix (`fix(chargen): Campaign CC gate round 1 Batch A`) added the data +plumbing everywhere (`ElementInfo.Invisible`, read in +`ElementReader.ApplyCanonicalLegacyProjection`; `UiElement.AuthoredInvisible`, +set in `LayoutImporter.BuildWidget`) but deliberately does NOT act on it +in the shared importer path — only `CharacterCreationUiController` +(`HideAuthoredInvisibleElements`) walks its own mounted subtree and +hides what it finds, chargen-scoped only. Register row AP-230 records +the split. + +Honoring the flag client-wide (setting `UiElement.Visible = false` +directly in `LayoutImporter.BuildWidget` when `info.Invisible` is true, +or an equivalent central chokepoint) is straightforward, but 1,083 +elements is its own visual-regression surface: any one of them could be +an element some OTHER screen currently relies on being visible despite +authoring the flag (e.g. a state-conditional visibility toggle that +happens to leave `0x3B=true` on its default/direct state while a +controller separately manages `Visible` at runtime). This needs its own +sweep — dump the 1,083 ids grouped by owning LayoutDesc/screen, spot-check +a representative sample per screen against retail, then flip the +importer-wide switch with a dedicated visual gate — not a one-line +change folded into an unrelated fix. + +Fix direction: (1) enumerate the 1,083 ids per LayoutDesc (a live-DAT +probe test, similar to `SpewBoxLayoutDumpDiagnostic`); (2) for each +distinct screen/LayoutDesc, confirm honoring the flag doesn't hide +something the runtime currently manages visibility of dynamically at that +SAME element id (would double-drive `Visible`); (3) flip the honor in +`LayoutImporter.BuildWidget` (mirroring the chargen-scoped code path +already proven live) and delete `CharacterCreationUiController`'s own +narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run +a full-client visual matrix, not just chargen. + ## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating) **Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ffebb196..1b3b3da7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 163 active rows (AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 164 active rows (AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -207,6 +207,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-230 | **Filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13).** Retail's `UIElement::OnSetAttribute @0x00462d80` case 8 (`GetPropertyName()-0x33==8`, property id `0x3B`, "Invisible") hides ANY element authoring that property `true` via `SetVisible(value==0)` — a general, importer-level mechanism. The blast-radius sweep this fix's investigation ran found **1,083 elements client-wide** author `P0x3B=true` (the Summary page's GM-only `0x10000403`/`0x10000494` labels among them — the user-reported "-Non-admin or Non-envoy" leak). Honoring the flag client-wide in `LayoutImporter`/`DatWidgetFactory` is its own separately-gated visual sweep (docs/ISSUES.md #408, since a mis-hidden element among 1,083 untested ones would silently vanish a control nobody asked to disappear); this fix instead reads the flag as a PURE DATA ADDITION (`ElementInfo.Invisible`, `UiElement.AuthoredInvisible` — populated everywhere, acted on nowhere by the shared path) and only the chargen screen's own mount (`CharacterCreationUiController.HideAuthoredInvisibleElements`, called once at construction) walks its own subtree and hides whatever the dat itself marked hidden. | `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.Invisible`, `ApplyCanonicalLegacyProjection`'s `0x3Bu` read); `src/AcDream.App/UI/UiElement.cs` (`AuthoredInvisible`); `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget`'s passthrough assignment); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`HideAuthoredInvisibleElements`) | The scoped fix closes the ONE reported, live-DAT-confirmed symptom (chargen's two GM labels) without touching any of the other 1,083 elements' visibility, each of which needs its OWN visual gate before the general importer-wide honor can ship safely — narrowing blast radius to a screen this same gate round is already re-testing end-to-end. | Every OTHER screen with an authored-invisible element still renders it (the general honor is #408, not yet shipped) — this row and #408 both retire together once the general sweep lands and passes its own visual gate. | `UIElement::OnSetAttribute @0x00462d80` (case 8, `SetVisible(value==0)`) | | AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | | AP-228 | **Filed 2026-08-16 at the CC5 re-review residual round (R4).** The Summary listbox's skill-row KEY (the skill's display name) sources from `ItemAppraisalTextFormatter.SkillName(int)` — a hardcoded English `switch` over the 54 skill ids — where retail's own `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` builds that same key from the DAT-sourced `SkillBase->_name` field via a `%hs` format substitution (`data_79f3f0`, `0x0047b90f`-`0x0047b915`). Same divergence CLASS as AP-226 (a hardcoded acdream string standing in for a DAT-sourced retail field) but the polarity is REVERSED: AP-226 is retail-static-vs-acdream-DAT-sourced, while here retail is the DAT-sourced side and acdream is the hardcoded side. The identical pattern is ALSO present at a second call site, CC4's Skills page (`CharacterCreationSkillsPage`), which builds its own row labels through the SAME `ItemAppraisalTextFormatter.SkillName` call — not a second, independent divergence, the same one surfacing twice. | `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs` (`SkillName`), consumed by `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`AddSkillBucket`) and `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` | `SkillName` already backs every OTHER retail skill-name surface acdream has shipped (item-appraisal skill lines, wield-requirement text, usage-limit text — `ItemAppraisalTextFormatter`'s whole existing surface) — the Summary/Skills chargen pages reusing it keeps one skill-name source across the client instead of introducing a second, DAT-reading one for chargen alone. English-only is consistent with the rest of the client's current localization posture (no other surface reads a localized skill name from the DAT either). | A non-English or modded DAT install would show its real, localized skill names on retail's character sheet and item-examine windows but acdream's chargen Summary/Skills pages would keep showing the hardcoded English name regardless — a localization-only divergence, never a wire or gameplay difference (the skill id sent over the wire is unaffected). | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` (`data_79f3f0`, `%hs` substitution `0x0047b90f`-`0x0047b915`) | | AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | @@ -403,7 +404,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | | AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | -| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | +| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 18a3184f..2fb92a5a 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -15,20 +15,90 @@ ISSUES.md; this doc is the six-page batch. description text that would confirm is itself broken, GF-2). ALSO: the open-roll's own rolled heritage shows NO lit dot on entry — every dot dark in the acdream screenshot. -- **GF-5 Skills page empty.** Nothing renders except the screen-description - textbox — no skill rows, no credits display. (CC5's residual round wired - `TemplateResolver` into the SKILLS page too — yet live rows are absent.) +- **GF-5 Skills page empty — FIXED (Campaign CC gate round + 1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows` + resolving `Templates[0]` (retail's own 3-child bucket-HEADER row, + `0x100002F4`) instead of `Templates[1]` (the REAL skill row, + `0x100002FF`, live-DAT-probe-confirmed 7 children) and requiring the + resolved root to be a `UiButton` (it's a plain container). Byte-traced + against `gmCGSkillsPage::DoSkillRecords @0x004817e0` + + `tagSkillRecord`'s copy-constructor field order to map every child id: + name (`0x10000301`), `pSkillLevelText` (`0x10000302`), `pUpCostText` + (`0x10000303`), `pSkillUpButton` (`0x10000304`), `pSkillDownButton` + (`0x10000305`), `pDownCostText` (`0x10000306`). Fixed to resolve + `Templates[1]`, wire the real per-row up/down arrow buttons to + `ListenToElementMessage @0x004814c0`'s own plain-click dispatch + (`IncreaseSkillLevel`/`DecreaseSkillLevel`), retiring AP-213's click-to- + advance/double-click-retreat single-button substitution (narrowed, not + fully retired — the flat-list-vs-four-bucket half stays). The credits- + caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is + UNCHANGED — Batch C's scope. - **GF-9 Appearance color swatches do nothing observable.** Clicking a color produces no visible change (model recolor absent). Could be a dead dispatch or could be working-but-invisible (AP-216 authored-art swatches + a recolor that fails); investigate, don't guess. - **GF-11a Town description text does not change** when switching towns. -- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name** — an - acdream-only text leak; retail's summary list has no such rows. -- **GF-15 Summary name entry DEAD + Finish unpressable.** Cannot type into - the name field at all; Finish cannot be pressed. Also the field is not - prefilled with retail's `[ Name` placeholder. This blocks the entire - create flow — the gate cannot proceed past Summary. +- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED + (Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat + property `0x3B` (Invisible — `UIElement::OnSetAttribute @0x00462d80` + case 8) was never read by the importer at all; elements `0x10000403` + ("Non-Admin") and `0x10000494` ("Non-Envoy") both author it `true` + (live-DAT-probe-confirmed, path `0x100003CC > 0x100003D0 > 0x100003D6 > + {0x10000403,0x10000494}`). Blast-radius sweep found **1,083 elements + client-wide** author the same flag — a blanket importer-wide honor is + its own visual gate, filed as ISSUES.md #408. This fix is CHARGEN-SCOPED + ONLY: `ElementInfo.Invisible`/`UiElement.AuthoredInvisible` are pure + data additions (read/stored everywhere, acted on nowhere by the shared + importer path), and `CharacterCreationUiController.HideAuthoredInvisibleElements` + walks its own mounted subtree once at construction and hides whatever + the dat itself marked hidden — by the authored flag, not a hardcoded id + list. Register AP-230 records the scoped-vs-general split. +- **GF-15 Summary name entry DEAD + Finish unpressable — FIXED + (Campaign CC gate round 1, Batch A) — this commit, LIVE-VERIFIED end to + end.** The live-repro investigation (offline `ACDREAM_OPEN_CHARGEN=1` + alone does NOT open the chargen screen — `RuntimeCharacterCreationState` + only activates via `LiveSessionController.StartAsync`'s authenticated- + connect path, `LiveSessionController.cs:791`; the repro required a real + connect to the project's own local ACE test server) showed the FIRST + click into the name field correctly focuses it and typing correctly + lands characters — the modal/pick/focus mechanics the earlier static + investigation examined were never broken. The REAL cause only surfaces + after the FIRST dialog opens: pressing Finish with an empty name + successfully creates the NoName `RetailMessageDialogView` + (`visible=true`, live-DAT-probe-confirmed nonzero popup/message/button + geometry — 400x95 popup, correctly centered) but it renders NOTHING and + silently absorbs every subsequent click across the WHOLE canvas, + including clicks aimed at the name field or Finish button underneath. + Root cause: `CharacterCreationUiController.Tick()` (and + `CharacterManagementUiController.Tick()`) call `UiRoot.BringToFront(Root)` + UNCONDITIONALLY every frame while their screen is open (needed so + chargen stays above the occluded character-management screen + underneath, register AP-229); a dialog's root is a direct sibling of + those screen roots under the same `UiRoot`, and + `RetailWindowManager.BringToFront` is a simple "highest ZOrder among + siblings + 1" — whichever sibling's own `BringToFront` call runs LAST in + a frame wins. `RetailDialogFactory.Tick()` never re-asserted its own + open dialogs' z-order, so the VERY NEXT frame's screen `Tick()` (which + always runs before the dialog factory's own `Tick()` in + `RetailUiRuntime.Tick(double)`'s per-frame sequence) silently buried the + dialog behind the screen's opaque backdrop — while the dialog remained + the registered `UiRoot.Modal` and kept EXCLUSIVE input priority + (`OnMouseDown`'s Modal-vs-bounds gate is independent of render/z-order). + Fixed by having `RetailDialogFactory.Tick()` re-raise every open dialog + (in `_openOrder`, so the most recently opened stays topmost) every tick, + matching retail's real always-on-top dialog behavior. Live-verified the + COMPLETE user sequence after the fix: click name field (focuses), type + (lands), press Finish empty (NoName dialog now VISIBLY renders: "You + must enter a name for this character!"), click OK (dismisses cleanly, + `Modal` clears), click the field again (still focusable/typable). The + `[ Name` prefill question is CLOSED, not a bug: byte-verified neither + `CharGenState::RandomizeCharacter @0x005c6d80` nor + `gmCGSummaryPage::InitializePage @0x0047bbf0` ever write text into the + name field (`InitializePage` only sets the input filter) — retail's + field is genuinely code-empty on a freshly-rolled character, matching + acdream's existing (correct) behavior; the `[ Name` the user saw was + most likely the field's own bracket-style empty-state chrome (GF-2/GF-12 + textbox-decoration family), not a missing name-prefill feature. ## Presentation families (retail parity) @@ -72,8 +142,12 @@ ISSUES.md; this doc is the six-page batch. GF-10 zoom art) — the AP-222 measured mechanism (state media authored vs applied) across widget kinds. 4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws. -5. Input routing on Summary (GF-15) — focus/typing path on the stacked - chargen screen. +5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked + chargen screen.~~ CLOSED: focus/typing routing was never broken (live- + verified); the real cause was `RetailDialogFactory` never re-asserting + its open dialogs' z-order against the chargen/char-management screens' + own per-tick `BringToFront` — see GF-15's own entry above. Batch A + fixed it. ## Process diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index 8dcf2d43..1ab988c3 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -23,16 +23,86 @@ namespace AcDream.App.UI.Layout; /// ChargenTableReaderInstalledDatTests) are filtered out via the /// same two-tier presence check RuntimeCharacterCreationState's /// TryGetSkillCost uses. +/// +/// +/// GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16): +/// RebuildRows used to require Templates[0]'s resolved root to +/// be a UiButton and treat its own Label as the row's whole content — +/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and +/// gmCGSkillsPage::DoSkillRecords @ 0x004817e0: +/// Templates[0] (0x100002F4, 3 children) is retail's own +/// bucket-HEADER row (Specialized/Trained/UseableUntrained/ +/// UnuseableUntrained — unused by this port's flat-list simplification, +/// AP-213), and the REAL skill row is Templates[1] +/// (0x100002FF, a plain container root, 7 children). Byte-traced +/// through DoSkillRecords' own GetChildRecursive calls + +/// tagSkillRecord's copy-constructor field order +/// (acclient.h struct gmCGSkillsPage::tagSkillRecord): +/// 0x10000301 = the skill NAME (set once at row build, never +/// refreshed — retail has no per-refresh name write either), +/// 0x10000302 = pSkillLevelText (the numeric skill SCORE, +/// CharGenState::GetSkillScore), 0x10000303 = +/// pUpCostText, 0x10000306 = pDownCostText, +/// 0x10000304 = pSkillUpButton (fires +/// IncreaseSkillLevel on plain click, +/// ListenToElementMessage @0x004814c0 case 0x10000304), +/// 0x10000305 = pSkillDownButton (fires +/// DecreaseSkillLevel, same dispatcher's case 0x10000305). +/// Both buttons fire on a PLAIN click, not click-vs-double-click on one +/// shared row — the row now wires exactly that, retiring AP-213's own +/// click-to-advance/double-click-retreat single-button substitution (the +/// row's still-simplified flat-list-vs-four-bucket half is untouched and +/// stays registered). +/// /// internal sealed class CharacterCreationSkillsPage : IDisposable { + /// Retail's own row-name id (set once at row build; retail + /// never re-writes it on refresh either — DoSkillRecords' + /// UIElement_Text::SetText(id_2, &var_138) at + /// 0x00481d5d runs OUTSIDE the per-refresh SetSkillText + /// call). + private const uint RowNameTextId = 0x10000301u; + + /// tagSkillRecord::pSkillLevelText — the numeric skill + /// SCORE (SetSkillText @0x00480600's + /// CharGenState::GetSkillScore call, "%d" format). + private const uint RowLevelTextId = 0x10000302u; + + /// tagSkillRecord::pUpCostText. + private const uint RowUpCostTextId = 0x10000303u; + + /// tagSkillRecord::pDownCostText. + private const uint RowDownCostTextId = 0x10000306u; + + /// tagSkillRecord::pSkillUpButton — + /// ListenToElementMessage's case 0x10000304 fires + /// IncreaseSkillLevel on a plain click (idMessage==1). + private const uint RowUpButtonId = 0x10000304u; + + /// tagSkillRecord::pSkillDownButton — same dispatcher's + /// case 0x10000305 fires DecreaseSkillLevel. + private const uint RowDownButtonId = 0x10000305u; + + /// One built skill row: the resolved Templates[1] + /// subtree plus the child widgets needs + /// every tick, resolved once at build time rather than re-walked per + /// refresh. + private readonly record struct SkillRow( + UiElement Root, + uint SkillId, + UiText? LevelText, + UiText? UpCostText, + UiText? DownCostText, + UiButton? UpButton, + UiButton? DownButton); + private readonly CharacterCreationRuntimeBindings _bindings; private readonly UiTemplateListBox? _list; private readonly UiButton? _credits; private readonly UiText? _infoTitle; private readonly UiText? _infoText; - private readonly List _rows = []; - private readonly Dictionary _rowSkillIds = []; + private readonly List _rows = []; private uint _lastHeritageId; private bool _rowsBuilt; private bool _disposed; @@ -55,6 +125,11 @@ internal sealed class CharacterCreationSkillsPage : IDisposable // faithful substitute is the button's own Label, which is exactly // the mechanism our factory already uses to surface a consumed // Type-12 child's text (register AD-103). + // + // GF-5 note: this clobbers the button's authored "Available Skill + // Credits" caption (retail's own m_pCreditsMeter is a SEPARATE + // widget from any caption text) — left as-is per the gate-round + // scope (Batch C owns the caption fix). _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; @@ -71,12 +146,8 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _rowsBuilt = true; } - foreach (UiButton row in _rows) - { - if (!_rowSkillIds.TryGetValue(row, out uint skillId)) - continue; - row.Label = FormatSkillLabel(view, snapshot.HeritageId, skillId); - } + foreach (SkillRow row in _rows) + RefreshRowValues(row, view, snapshot); if (_credits is { } credits) credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); @@ -84,45 +155,94 @@ internal sealed class CharacterCreationSkillsPage : IDisposable private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) { - foreach (UiButton row in _rows) + foreach (SkillRow row in _rows) { - row.OnClick = null; - row.OnDoubleClick = null; + if (row.UpButton is not null) row.UpButton.OnClick = null; + if (row.DownButton is not null) row.DownButton.OnClick = null; } _rows.Clear(); - _rowSkillIds.Clear(); _list?.Flush(); if (_list is null - || _list.Templates.Count == 0 + || _list.Templates.Count < 2 || _list.TemplateResolver is null || !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) { return; } - UiTemplateListEntry template = _list.Templates[0]; + // Templates[1] (0x100002FF) is the REAL skill row — see this + // class's own doc comment for the full byte trace. + UiTemplateListEntry template = _list.Templates[1]; for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) { if (!IsCostable(heritage, view.Options, skillId)) continue; if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId) - is not UiButton row) + is not { } rowRoot) { continue; } - _list.AddPrebuiltRow(row); - row.Enabled = true; - row.SuppressSelfToggle = true; + _list.AddPrebuiltRow(rowRoot); + + if (UiElement.FindDescendant(rowRoot, RowNameTextId) is UiText nameText) + SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId)); + UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText; + UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText; + UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText; + UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton; + UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton; + uint capturedSkillId = skillId; - row.OnClick = () => Advance(capturedSkillId); - row.OnDoubleClick = () => Retreat(capturedSkillId); - _rows.Add(row); - _rowSkillIds[row] = skillId; + if (upButton is not null) + upButton.OnClick = () => Advance(capturedSkillId); + if (downButton is not null) + downButton.OnClick = () => Retreat(capturedSkillId); + + _rows.Add(new SkillRow( + rowRoot, skillId, levelText, upCostText, downCostText, upButton, downButton)); } } + private void RefreshRowValues( + SkillRow row, + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId); + (int trainedCost, int specializedCost) = GetCosts(view, snapshot.HeritageId, row.SkillId); + uint score = _bindings.GetSkillScore?.Invoke(row.SkillId, snapshot.Attributes, level) ?? 0u; + + if (row.LevelText is { } levelText) + SetLine(levelText, score.ToString(CultureInfo.InvariantCulture)); + + // SetSkillText @0x00480600's own per-state up/down cost pair: at + // Untrained, up=trainCost (down blank, nothing below Untrained); at + // Trained, up=(specCost-trainCost), down=trainCost; at Specialized, + // up=blank (nothing above Specialized), down=(specCost-trainCost). + // Retail also blanks a cost >= 999 (data_794320, an empty + // PStringBase) instead of showing the raw number. + (int? upCost, int? downCost) = level switch + { + ChargenSkillAdvancementClass.Specialized => + ((int?)null, (int?)(specializedCost - trainedCost)), + ChargenSkillAdvancementClass.Trained => + ((int?)(specializedCost - trainedCost), (int?)trainedCost), + _ => ((int?)trainedCost, (int?)null), + }; + if (row.UpCostText is { } upCostText) + SetLine(upCostText, FormatCost(upCost)); + if (row.DownCostText is { } downCostText) + SetLine(downCostText, FormatCost(downCost)); + } + + private static string FormatCost(int? cost) => + cost is int c && c < 999 ? c.ToString(CultureInfo.InvariantCulture) : string.Empty; + + private static void SetLine(UiText text, string content) => + text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; + /// Same dictionary-presence gate as /// RuntimeCharacterCreationState.TryGetSkillCost — heritage list /// first, global SkillTable fallback. @@ -133,19 +253,6 @@ internal sealed class CharacterCreationSkillsPage : IDisposable heritage.SkillCostsBySkillId.ContainsKey(skillId) || options.GlobalSkillCostsBySkillId.ContainsKey(skillId); - private string FormatSkillLabel( - IRuntimeCharacterCreationView view, - uint heritageId, - uint skillId) - { - string name = ItemAppraisalTextFormatter.SkillName((int)skillId); - ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); - (int trainedCost, int specializedCost) = GetCosts(view, heritageId, skillId); - return string.Create( - CultureInfo.InvariantCulture, - $"{name}: {level} (T{trainedCost}/S{specializedCost})"); - } - private static (int Trained, int Specialized) GetCosts( IRuntimeCharacterCreationView view, uint heritageId, @@ -161,10 +268,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable return (0, 0); } - /// OnClick: one step up (Untrained/Inactive -> Trained, - /// Trained -> Specialized). Simplified from retail's separate - /// Increase/Decrease affordances (IncreaseSkillLevel/ - /// DecreaseSkillLevel) to one click target per row. + /// pSkillUpButton click: IncreaseSkillLevel + /// @0x00480ca0 — Untrained/Inactive -> Trained, + /// Trained -> Specialized. private void Advance(uint skillId) { if (_disposed) @@ -177,8 +283,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _bindings.SpecializeSkill(skillId); } - /// OnDoubleClick: one step down (Specialized -> Trained, - /// Trained -> Untrained). + /// pSkillDownButton click: DecreaseSkillLevel + /// @0x00480d60 — Specialized -> Trained, + /// Trained -> Untrained. private void Retreat(uint skillId) { if (_disposed) @@ -196,13 +303,12 @@ internal sealed class CharacterCreationSkillsPage : IDisposable if (_disposed) return; _disposed = true; - foreach (UiButton row in _rows) + foreach (SkillRow row in _rows) { - row.OnClick = null; - row.OnDoubleClick = null; + if (row.UpButton is not null) row.UpButton.OnClick = null; + if (row.DownButton is not null) row.DownButton.OnClick = null; } _rows.Clear(); - _rowSkillIds.Clear(); _list?.Flush(); if (_list is not null) _list.TemplateResolver = null; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index bf564a55..d736bf57 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -308,6 +308,41 @@ internal sealed class CharacterCreationUiController : IDisposable _appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance); _townTab.OnClick = () => ApplyProgressState(Page.Town); _summaryTab.OnClick = () => ApplyProgressState(Page.Summary); + + // GF-13 (Campaign CC gate round 1, Batch A): honor the authored + // Invisible flag (dat property 0x3B) chargen-scoped only — see + // HideAuthoredInvisibleElements's own doc comment. + HideAuthoredInvisibleElements(Root); + } + + /// + /// GF-13 (Campaign CC gate round 1, Batch A). The user's live gate + /// reported an acdream-only "-Non-admin or Non-envoy" text leak below the + /// Summary name field. Root cause: elements 0x10000403 ("Non- + /// Admin") and 0x10000494 ("Non-Envoy") author dat property + /// 0x3B (Invisible) = — retail's + /// UIElement::OnSetAttribute @0x00462d80 case 8 + /// (GetPropertyName()-0x33 == 8, property id 0x3B) hides any + /// element authoring it via SetVisible(value == 0). acdream's + /// shared never read this property at all + /// (it now does, into / + /// , a pure data addition), so + /// every one of the 1,083 elements client-wide that author it rendered + /// regardless. A blanket importer-wide honor is its own separately-gated + /// visual sweep (docs/ISSUES.md #408) — this method is the NARROW, + /// chargen-scoped fix: walk this screen's own mounted subtree once at + /// construction and hide anything the dat itself marked hidden, by the + /// AUTHORED FLAG rather than a hardcoded id list, so any other + /// authored-invisible element under this root (not just the two the user + /// happened to see) is honored the same way. Register AP-230 records the + /// scoped-vs-general split. + /// + private static void HideAuthoredInvisibleElements(UiElement element) + { + if (element.AuthoredInvisible) + element.Visible = false; + foreach (UiElement child in element.Children) + HideAuthoredInvisibleElements(child); } internal UiElement Root => _layout.Root; diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 085d5d2b..e1204f9e 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -225,6 +225,25 @@ public sealed class ElementInfo /// public uint ScrollbarElementId; + /// + /// GF-13 (Campaign CC gate round 1, Batch A): the authored Invisible flag + /// from dat property 0x3B (BoolBaseProperty). Retail + /// UIElement::OnSetAttribute @0x00462d80's case 8 + /// (BaseProperty::GetPropertyName(esi) - 0x33 == 8, i.e. property + /// id 0x33 + 8 = 0x3B): this->vtable->SetVisible(value == 0) — + /// an authored true HIDES the element at construction. Populated the + /// same way as / + /// (recomputed fresh from the effective merged state every call), but this + /// is a PURE DATA ADDITION: the shared / + /// path does not act on it. 1,083 elements + /// author this flag client-wide (docs/ISSUES.md #408, its own separately- + /// gated general-honor item) — only screens that explicitly walk their own + /// mounted subtree and check this field may hide elements by it (see + /// CharacterCreationUiController's chargen-scoped honor, register + /// AP-230). + /// + public bool Invisible; + /// /// Resolves a property for a state using retail's DirectState-as-base rule. A /// named state's key overrides DirectState by presence, including false/zero. @@ -529,6 +548,16 @@ public static class ElementReader // (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19). info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u); info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u); + + // GF-13: Invisible (0x3B), BoolBaseProperty. Retail + // UIElement::OnSetAttribute @0x00462d80 case 8 — SetVisible(value == 0), + // so an authored true HIDES the element. Read via the same + // TryGetEffectiveBool the DirectState/default-state resolution rules + // already use for every other canonical-projection property above. + if (info.TryGetEffectiveBool(0x3Bu, out bool invisible)) + { + info.Invisible = invisible; + } } private static List ReadTabTable(ElementInfo info) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 3c3f72d4..06d78aa9 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -117,6 +117,10 @@ public static class LayoutImporter var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve); if (w is null) return null; // Type-12 style prototype — skip + // GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own + // doc comment for why this does NOT set Visible here. + w.AuthoredInvisible = info.Invisible; + if (info.Id != 0) byId[info.Id] = w; // Behavioral widgets that draw their full appearance + reproduce their dat diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index f72d7283..9efc211c 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -255,11 +255,53 @@ public sealed class RetailDialogFactory : IDisposable return false; } + /// + /// GF-15 fix (Campaign CC gate round 1, Batch A, 2026-08-16). Live-repro- + /// confirmed root cause: CharacterCreationUiController.Tick and + /// CharacterManagementUiController.Tick both call + /// UiRoot.BringToFront(Root) UNCONDITIONALLY on every frame while + /// their screen is open — a per-tick "stay on top of my sibling screen" + /// assertion (needed so chargen never bleeds input to the occluded + /// char-management screen underneath it, register AP-229). A dialog this + /// factory opens is ALSO a direct sibling of those screen roots under + /// the same UiRoot (_host.AddChild(view.Root) in + /// ), competing for the SAME z-order slot. + /// is a simple "highest + /// ZOrder among _root's direct children + 1" — whichever sibling's + /// own BringToFront call runs LAST in a frame wins the top slot. + /// Before this fix, this method never re-asserted a dialog's own + /// z-order after the one-time raise in , so + /// the VERY NEXT frame's screen Tick() (which always runs before + /// this factory's own Tick() in + /// RetailUiRuntime.Tick(double)'s per-frame sequence) silently + /// buried the dialog behind the screen's opaque backdrop — while the + /// dialog remained the registered and kept + /// EXCLUSIVE input priority (OnMouseDown's Modal-vs-bounds gate is + /// independent of render/z-order). The user-visible symptom: press + /// Finish empty → the NoName dialog is created successfully + /// (visible=true, correct geometry, live-DAT-probe-confirmed) but + /// renders NOTHING, and every subsequent click across the WHOLE canvas + /// resolves to the invisible dialog root instead of the name field or + /// Finish button underneath — both GF-15 symptoms from one mechanism. + /// Retail's real dialogs are always-on-top overlays by construction (a + /// separate presentation layer, not a z-ordered sibling of the game UI); + /// re-asserting every open dialog's z-order here, every tick, in + /// order (so the MOST RECENTLY opened dialog — + /// the same one already treats as + /// authoritative — ends up on top) reproduces that invariant without + /// touching either screen controller's own already-verified raise. + /// public void Tick() { RetryFailedDialogs(); foreach (DialogInfo info in _openOrder.ToArray()) - info.View?.Tick(); + { + if (info.View is { } view) + { + _host.BringToFront(view.Root); + view.Tick(); + } + } } /// diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index cc7fcbfe..668d51fc 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -57,6 +57,19 @@ public abstract class UiElement /// Human-readable name for debugging / FindByName. public string? Name { get; init; } + /// + /// GF-13 (Campaign CC gate round 1, Batch A): mirrors + /// ElementInfo.Invisible (dat property 0x3B) — a PURE DATA + /// PASSTHROUGH set by LayoutImporter.BuildWidget at construction. + /// The shared importer does NOT act on this flag (1,083 elements author + /// it client-wide, docs/ISSUES.md #408); it exists only so a screen that + /// owns its own mounted subtree can honor it explicitly, the way + /// CharacterCreationUiController does for the chargen screen + /// (register AP-230). Reading this never changes by + /// itself. + /// + public bool AuthoredInvisible { get; internal set; } + private readonly Dictionary _stateCursors = new(); /// Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState. diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 9d450c08..7d1db271 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -617,6 +617,189 @@ public sealed class CharacterCreationLiveDatTests + "gate argument needs re-verification for this skill."); } + /// + /// GF-13 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed: + /// the GM-only labels 0x10000403 ("Non-Admin") and + /// 0x10000494 ("Non-Envoy") both live under the Summary page + /// (0x100003D6, path 0x100003CC > 0x100003D0 > + /// 0x100003D6 > {0x10000403,0x10000494}) and both author dat + /// property 0x3B (Invisible) = — the exact + /// mechanism retail's UIElement::OnSetAttribute @0x00462d80 case 8 + /// hides them by. Pins the DATA half () + /// against the installed EoR dat. + /// + [InstalledDatFact] + public void SummaryPage_NonAdminNonEnvoyLabels_AuthorInvisibleTrue() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + foreach (uint targetId in new[] { 0x10000403u, 0x10000494u }) + { + ElementInfo? found = FindInfo(rootInfo, targetId); + Assert.NotNull(found); + Assert.True( + found!.Invisible, + $"element 0x{targetId:X8} must author dat property 0x3B (Invisible) = true."); + } + } + + /// + /// GF-13: the BEHAVIOR half — after the real controller mounts through + /// (not a raw + /// call), the two authored-invisible + /// elements are not . Exercises + /// HideAuthoredInvisibleElements's real chargen-scoped honor path, + /// not just the data plumbing the sibling test above pins. + /// + [InstalledDatFact] + public void SummaryPage_NonAdminNonEnvoyLabels_HiddenAfterControllerMount() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + var host = new UiRoot(); + var dialogs = MakeDialogFactory(dats, host); + var bindings = new CharacterCreationRuntimeBindings( + () => null, + _ => default, + _ => default, + _ => default, + (_, _) => default, + (_, _) => default, + _ => default, + _ => default, + _ => default, + _ => default, + _ => default, + () => { }); + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) => + LayoutImporter.Import( + dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root; + + CharacterCreationUiController? controller = + CharacterCreationUiController.CreateDetached( + host, screen, ResolveTemplate, dialogs, bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long")); + Assert.NotNull(controller); + + foreach (uint targetId in new[] { 0x10000403u, 0x10000494u }) + { + UiElement found = Assert.IsAssignableFrom( + UiElement.FindDescendant(controller!.Root, targetId)); + Assert.True(found.AuthoredInvisible, $"0x{targetId:X8} must carry AuthoredInvisible."); + Assert.False(found.Visible, $"0x{targetId:X8} must be hidden after mount."); + } + + controller!.Dispose(); + dialogs.Dispose(); + } + + /// + /// GF-5 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed + /// (raw ElementDesc.Type — the same id space as retail's + /// DynamicCast tags, 1=Button, 12=Text): the Skills listbox + /// authors exactly two templates. Templates[0] + /// (0x100002F4) is retail's own 3-child bucket-HEADER row + /// (unused by this port's flat-list simplification, AP-213). + /// Templates[1] (0x100002FF) is the REAL skill row — a + /// plain container root (rawType 3, NOT a Button), 7 children, byte- + /// traced against gmCGSkillsPage::DoSkillRecords @ 0x004817e0 + + /// tagSkillRecord's copy-constructor field order + /// (acclient.h): name (0x10000301, Text), pSkillLevelText + /// (0x10000302, Text), pUpCostText (0x10000303, Text), + /// pSkillUpButton (0x10000304, Button), pSkillDownButton + /// (0x10000305, Button), pDownCostText (0x10000306, Text). + /// See 's own class doc for the + /// full trace. + /// + [InstalledDatFact] + public void SkillsPage_RealRowTemplate_HasNameLevelCostTextAndArrowButtons() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement skillsRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SkillsPageElementId)); + UiTemplateListBox list = Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003F7u)); + + Assert.Equal(2, list.Templates.Count); + + UiTemplateListEntry realRowTemplate = list.Templates[1]; + Assert.Equal(0x100002FFu, realRowTemplate.TemplateElementId); + + UiElement? row = LayoutImporter.Import( + dats, + realRowTemplate.TemplateLayoutId, + realRowTemplate.TemplateElementId, + _ => (0u, 0, 0), + null)?.Root; + UiElement realRow = Assert.IsAssignableFrom(row); + Assert.IsNotType(realRow); + + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000301u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000302u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000303u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000306u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000304u)); + Assert.IsType(UiElement.FindDescendant(realRow, 0x10000305u)); + + // Templates[0] (0x100002F4) is retail's bucket-header row — unused + // by RebuildRows, still confirmed present so a future revision that + // drops it or changes its shape shows up here. + Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId); + } + + /// + /// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed + /// during the investigation: the Message dialog catalog's popup + /// (0x3D), message text (0x3E), and OK button + /// (0x26) all author REAL, nonzero geometry (popup 400x95, + /// centered by ) — + /// ruling out a zero-size/collapsed-layout explanation for the dialog + /// rendering nothing. The actual root cause was a Z-ORDER bug (the + /// chargen screen's own per-tick BringToFront burying the dialog + /// behind its opaque backdrop while the dialog kept exclusive + /// input priority — fixed in + /// ). This test pins the geometry + /// half so a future DAT revision that collapses the popup/message/button + /// to zero size is caught here instead of silently reintroducing an + /// invisible dialog. + /// + [InstalledDatFact] + public void MessageDialogCatalog_PopupMessageAndOkButton_AuthorNonzeroGeometry() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); + + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos(dats, dialogDid, 0x24u)); + Assert.Equal(800f, rootInfo.Width); + Assert.Equal(600f, rootInfo.Height); + + ElementInfo popup = Assert.IsType(FindInfo(rootInfo, 0x3Du)); + Assert.True(popup.Width > 0f && popup.Height > 0f); + + ElementInfo message = Assert.IsType(FindInfo(rootInfo, 0x3Eu)); + Assert.True(message.Width > 0f && message.Height > 0f); + + ElementInfo okButton = Assert.IsType(FindInfo(rootInfo, 0x26u)); + Assert.True(okButton.Width > 0f && okButton.Height > 0f); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 4dc9b0e6..462b4622 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -279,8 +279,16 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(42, environment.Runtime.LastAttributeValue); } + /// + /// GF-5 fix (2026-08-16): the row is now the REAL Templates[1] + /// (0x100002FF) subtree — a plain container root with the two + /// separate arrow buttons retail authors (pSkillUpButton + /// 0x10000304 / pSkillDownButton 0x10000305), each + /// firing on a PLAIN click (ListenToElementMessage @0x004814c0), + /// not the old single-button click-vs-double-click substitution. + /// [Fact] - public void SkillsRow_Click_TrainsThenSpecializes() + public void SkillsRow_ArrowClick_TrainsThenSpecializes() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); @@ -288,30 +296,71 @@ public sealed class CharacterCreationUiControllerTests environment.TabButton(CharacterCreationUiController.SkillsTabElementId) .OnClick!(); - // Rows are built in ascending skill-id order (RebuildRows' 1..54 - // walk over IsCostable ids) — SkillSpecializable's row is identified - // by its label prefix (FormatSkillLabel's "{name}: ..." shape) - // rather than instance identity, since the controller owns the - // row->skillId map privately. - string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable); - UiButton row = environment.SkillsList().ViewportForTest!.Children - .OfType() - .Single(candidate => candidate.Label!.StartsWith( - skillName + ":", StringComparison.Ordinal)); + (UiButton up, UiButton down) = environment.SkillRowArrows(SkillSpecializable); - row.OnClick!(); + up.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Trained, environment.Runtime.GetSkillLevel(SkillSpecializable)); - row.OnClick!(); + up.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Specialized, environment.Runtime.GetSkillLevel(SkillSpecializable)); - row.OnDoubleClick!(); + down.OnClick!(); Assert.Equal(ChargenSkillAdvancementClass.Trained, environment.Runtime.GetSkillLevel(SkillSpecializable)); } + /// + /// GF-5: the listbox produces one row per costable skill through the + /// REAL template-resolver path (Templates[1], not the bucket- + /// header Templates[0]), with name/level/cost values populated + /// from a known snapshot — the exact regression CC5's fixture tests + /// never had (they called UiField.SetText/UiButton.OnClick + /// directly, bypassing RebuildRows' own template resolution entirely). + /// + [Fact] + public void SkillsPage_Rows_RenderNameAndLevelCostValues_ThroughTheRealTemplate() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId) + .OnClick!(); + + IReadOnlyList rows = environment.SkillsList().ViewportForTest!.Children; + // Aluvian's fixture only costs SkillTrainOnly(1)/SkillSpecializable(2). + Assert.Equal(2, rows.Count); + + UiElement row = Assert.Single(rows, candidate => + UiElement.FindDescendant(candidate, 0x10000301u) is UiText name + && JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); + + // FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10. + UiText level = Assert.IsType(UiElement.FindDescendant(row, 0x10000302u)); + Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level)); + + // Default (never-touched) level: up cost = trained cost (2), down + // cost blank (nothing below Untrained/Inactive). + UiText upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u)); + UiText downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u)); + Assert.Equal("2", JoinedText(upCost)); + Assert.Equal(string.Empty, JoinedText(downCost)); + + // Advancing to Trained flips the cost pair: up = specCost-trainCost + // (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a + // lightweight stub that doesn't bump Revision itself (unlike + // production's TrySetSkillLevel, RuntimeCharacterCreationState.cs + // ~1045), so force one the same way the file's other post-click + // refresh assertions do. + environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); + RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 }; + environment.Controller.Tick(); + Assert.Equal("4", JoinedText(upCost)); + Assert.Equal("2", JoinedText(downCost)); + } + [Fact] public void TownButton_SelectsTheLiteralStartAreaIndex() { @@ -862,6 +911,94 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal("No name entered.", environment.LastDialogMessage()); } + /// + /// GF-15 (Campaign CC gate round 1, Batch A). The exact user sequence, + /// driven through the REAL / + /// event pipeline — every OTHER Summary-page + /// test in this file calls field.SetText/UiButton.OnClick!() + /// directly, which bypasses 's own pick/focus/Modal + /// dispatch entirely and is exactly why those tests kept passing while + /// the live screen was dead. Root cause (live-repro-confirmed): + /// 's own per-tick + /// UiRoot.BringToFront(Root) (needed so chargen stays above the + /// occluded character-management screen, AP-229) buried any dialog + /// opened while chargen is active on the VERY NEXT frame, because + /// never re-asserted its own open + /// dialogs' z-order — fixed by having it do so, in open-order, every + /// tick. + /// + [Fact] + public void Finish_EmptyName_RealEventPath_DialogSurvivesTheNextFrameTick_AndFieldRefocusableAfterDismiss() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + // Heritage/gender must be selected so Finish's HeritageOrGenderUnset + // local refusal (which has no retail dialog) can't preempt the + // NoName refusal this test exercises. + SelectAluvianMale(environment); + GoToSummary(environment); + + UiField nameField = environment.SummaryNameField(); + UiButton finishButton = environment.Button(CharacterCreationUiController.FinishElementId); + + // (1) A real mouse-down at the field's own screen rect sets + // KeyboardFocus to it. + Vector2 fieldPos = nameField.ScreenPosition; + environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2); + Assert.Same(nameField, environment.Host.KeyboardFocus); + + // (2) A subsequent OnChar lands a character in the field through + // the real event pipeline (UiRoot.OnChar -> BubbleEvent -> + // UiField.OnEvent), not a direct SetText call -- then the user's + // own live-repro clear (repeated Backspace) empties it again, so + // the field is genuinely empty when Finish commits it below + // (clicking Finish blurs the field, which commits its text -- + // CommitNameFromField -- exactly like a real click-away would). + environment.Host.OnChar('Z'); + Assert.Equal("Z", nameField.Text); + nameField.Backspace(); + Assert.Equal(string.Empty, nameField.Text); + + // (3) A real click on Finish while the field is empty. UiButton's + // own click fires on the press-release pair (OnMouseUp with the + // same target still Captured from OnMouseDown), matching every + // other real click below. + Vector2 finishPos = finishButton.ScreenPosition; + environment.Host.OnMouseDown(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2); + environment.Host.OnMouseUp(UiMouseButton.Left, (int)finishPos.X + 2, (int)finishPos.Y + 2); + + Assert.Equal(1, environment.Runtime.FinishCallCount); + Assert.True(environment.Dialogs.IsOpen); + UiPanel dialogModal = Assert.IsAssignableFrom(environment.Host.Modal); + + // (4) Reproduce the exact bug window: one full frame's worth of + // ticks in production order (CharacterCreationController.Tick then + // DialogFactory.Tick, RetailUiRuntime.Tick(double)'s own sequence). + // Before the fix, step 4a alone buried the dialog; the dialog must + // still sit at or above the screen root's z-order once step 4b (the + // fix) runs, matching retail's always-on-top dialog behavior. + environment.Controller.Tick(); + environment.Dialogs.Tick(); + Assert.True(dialogModal.ZOrder >= environment.Controller.Root.ZOrder); + + // (5) Dismiss through the real click path -- the OK button's own + // screen rect, not ConfirmActiveDialog's direct OnClick! shortcut. + UiButton okButton = Assert.IsType( + UiElement.FindDescendant(dialogModal, RetailMessageDialogView.OkButtonId)); + Vector2 okPos = okButton.ScreenPosition; + environment.Host.OnMouseDown(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2); + environment.Host.OnMouseUp(UiMouseButton.Left, (int)okPos.X + 2, (int)okPos.Y + 2); + Assert.False(environment.Dialogs.IsOpen); + Assert.Null(environment.Host.Modal); + + // (6) The user's own final check: the field is still typable + // afterward, through the same real click+char path. + environment.Host.OnMouseDown(UiMouseButton.Left, (int)fieldPos.X + 2, (int)fieldPos.Y + 2); + Assert.Same(nameField, environment.Host.KeyboardFocus); + environment.Host.OnChar('Q'); + Assert.Contains('Q', nameField.Text); + } + [Fact] public void Finish_UnspentCredits_ShowsCreditWarning_ConfirmResendsWithConfirmedFlag() { @@ -1345,6 +1482,22 @@ public sealed class CharacterCreationUiControllerTests public UiTemplateListBox SkillsList() => Assert.IsType(Screen.FindElement(0x100003F7u)); + /// GF-5: locates a built skill row by its name text + /// (0x10000301) and returns its up (0x10000304, + /// pSkillUpButton) / down (0x10000305, + /// pSkillDownButton) arrow buttons. + public (UiButton Up, UiButton Down) SkillRowArrows(uint skillId) + { + string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId); + UiElement row = Assert.Single( + SkillsList().ViewportForTest!.Children, + candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name + && JoinedText(name) == skillName); + UiButton up = Assert.IsType(UiElement.FindDescendant(row, 0x10000304u)); + UiButton down = Assert.IsType(UiElement.FindDescendant(row, 0x10000305u)); + return (up, down); + } + public UiTemplateListBox SummaryListBox() => Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId)); @@ -1829,7 +1982,14 @@ public sealed class CharacterCreationUiControllerTests root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId)); - root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId)); + // GF-15 fix round: FinishElementId needs a real, non-default + // position for the real-event-path regression test — see the + // matching comment on the Summary name field below. Clear of both + // the listbox (20,40)-(420,340) and the field (450,100)-(490,116). + ElementInfo finishInfo = ButtonInfo(CharacterCreationUiController.FinishElementId); + finishInfo.X = 600f; + finishInfo.Y = 500f; + root.Children.Add(finishInfo); root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId)); root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId)); @@ -1911,7 +2071,13 @@ public sealed class CharacterCreationUiControllerTests Width = 300f, Height = 320f, }; - list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu)); + // GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row + // (0x100002F4, unused by this port's flat-list simplification); + // [1] (0x100002FF) is the REAL skill row RebuildRows now resolves — + // see CharacterCreationSkillsPage's own class doc for the byte + // trace pinning both ids and their child shapes. + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u)); + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu)); page.Children.Add(list); page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge page.Children.Add(TextInfo(0x100003FBu)); @@ -2020,8 +2186,38 @@ public sealed class CharacterCreationUiControllerTests return spin; } - private static UiElement BuildSkillRowTemplate(uint templateElementId) => - LayoutImporter.Build( + /// + /// GF-5: mirrors Templates[1]'s real installed-DAT shape + /// (0x100002FF, live-DAT-probe-confirmed against + /// CharacterCreationSkillsPage's own class doc) — a plain + /// container root, NOT a UiButton, with the six children + /// RebuildRows/RefreshRowValues resolve by id. Template + /// 0x100002F4 (retail's unused bucket-header row) falls back to + /// a bare button shape since this port's flat-list simplification never + /// resolves it. + /// + private static UiElement BuildSkillRowTemplate(uint templateElementId) + { + if (templateElementId == 0x100002FFu) + { + var row = new ElementInfo + { + Id = templateElementId, + Type = 3u, + Width = 280f, + Height = 16f, + }; + row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop + row.Children.Add(TextInfo(0x10000301u)); // name + row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText + row.Children.Add(TextInfo(0x10000303u)); // pUpCostText + row.Children.Add(ButtonInfo(0x10000304u)); // pSkillUpButton + row.Children.Add(ButtonInfo(0x10000305u)); // pSkillDownButton + row.Children.Add(TextInfo(0x10000306u)); // pDownCostText + return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root; + } + + return LayoutImporter.Build( new ElementInfo { Id = templateElementId, @@ -2031,6 +2227,7 @@ public sealed class CharacterCreationUiControllerTests }, _ => (0u, 0, 0), null).Root; + } // ── Summary page fixture (CC5) ─────────────────────────────────────── // Template element ids match the LIVE-DAT-probe-confirmed retail ones @@ -2066,7 +2263,21 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(list); page.Children.Add(ScrollbarInfo(CharacterCreationSummaryPage.ScrollId)); - page.Children.Add(EditableFieldInfo(CharacterCreationSummaryPage.NameTextId)); + + // GF-15 fix round (2026-08-16): the real-event-path regression test + // (Finish_EmptyName_RealEventPath_...) drives UiRoot.OnMouseDown by + // actual screen coordinates, unlike every other test in this file. + // EditableFieldInfo's own default X/Y (0,0) would collide with the + // tab strip's own default (0,0) position (BuildScreen's tab buttons + // are never given explicit coordinates either — no prior test + // needed them, since they all click via .OnClick!() directly) — + // clear of both the listbox (20,40)-(420,340) and the default- + // positioned tab/nav buttons at Y=0. + ElementInfo nameFieldInfo = EditableFieldInfo(CharacterCreationSummaryPage.NameTextId); + nameFieldInfo.X = 450f; + nameFieldInfo.Y = 100f; + page.Children.Add(nameFieldInfo); + page.Children.Add(TextInfo(CharacterCreationSummaryPage.HowToTextId)); var viewport = new ElementInfo From 7d09821fdc7ff3873bcb5973bcb3c5216c22a96d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 11:37:09 +0200 Subject: [PATCH 115/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20B=20=E2=80=94=20authored=20selection=20stat?= =?UTF-8?q?es,=20label=20state,=20zoom/swatch=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GF-1/GF-8: UiButton now recognizes retail's custom Unselected/Selected radio-pair (0x10000016/0x10000017), bypassing the standard Normal/ Highlight machine that never admitted those state names — .Selected now lights the heritage/template/gender/Face-Clothes rows it was always a no-op for. AP-222/GF-11b: per-state label color/outline (dat 0x1B/0x21) now applies off the REQUESTED retail state id, not the art-gated committed ActiveState — resolves the Appearance spins' current-part highlight (text recolors even though no Highlight art exists on either client) and the Town caption's Normal-to-white swap. GF-11c: UiButton.LabelBox lets a lifted caption with its own authored rect draw there instead of the face-relative offset that's only correct when the label is authored directly on the button (heritage/template family, unchanged). GF-9: wires the real nine companion overlay elements (SetColor's SetVisible mechanism) that swatch clicks were always meant to drive, retiring AP-215 item 1 (the swatch.Selected substitution was a permanent no-op — swatches author no Highlight media at all). GF-10: zoom buttons now set the retail-mirrored mutual-exclusive Highlight/Normal pair on click; InitializePage carries no initial SetState for either button, so both stay at "Normal" until first click. Register: AP-222 retired (mechanism identified and ported), AP-215 narrowed (item 1 retired, item 2 unrelated and unchanged), row count recount corrected 164 (was already one high before this batch). App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Fixture + live- DAT tests only — no graphical client launch. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 5 +- ...-08-16-campaign-cc-gate-round1-findings.md | 143 ++++++++-- src/AcDream.App/UI/IUiDatStateful.cs | 20 ++ .../Layout/CharacterCreationAppearancePage.cs | 71 ++++- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 36 ++- src/AcDream.App/UI/Layout/ElementReader.cs | 62 +++++ src/AcDream.App/UI/UiButton.cs | 128 ++++++++- .../Layout/CharacterCreationLiveDatTests.cs | 253 ++++++++++++++++++ .../CharacterCreationUiControllerTests.cs | 112 +++++++- .../UI/Layout/DatWidgetFactoryTests.cs | 90 +++++++ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 165 ++++++++++++ 11 files changed, 1043 insertions(+), 42 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1b3b3da7..023a0877 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 164 active rows (AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 164 active rows (recount at this same edit: the row count this header carried before Batch B was already one high relative to the physical table — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change; AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -396,14 +396,13 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | -| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions).** Two narrow, DECIDED substitutions where acdream reaches the same functional selection through a different widget mechanism than retail's own: (1) the nine color swatches (`0x1000030f-0x10000317`) use their own `UiButton.Selected` highlight state for "this is the current color" instead of toggling the separate Type-3 companion overlay element (`0x10000318-0x10000320`) retail's `SetColor @ 0x0047DD50` shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — the composited pixel result is UNVERIFIED to match, not asserted identical (same "measured, not assumed" discipline AD-103's own F5 note established for a different swallowed-child case). (2) the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons is out of this round's scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop; `SetStyleSpinLabel`) | Both substitutions reach the SAME underlying selection (the swatch highlight still shows which color index is active; the ordinal still lets a player cycle deterministically and see which slot they're on) through existing widget primitives (`UiButton.Selected`, `UiButton.Label`) rather than adding new rendering infrastructure (a second overlay-visibility channel, or an icon-texture pipeline) this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a different (simpler) selected-swatch visual and text labels where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close the label half of this row. | `gmCGAppearancePage::SetColor @0x0047DD50` (the `m_tColorWheel` overlay toggle); `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | +| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED.** What CLOSED this round: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest, retiring the prior `UiButton.Selected` highlight substitution outright (measured against the installed dat: the swatch buttons author only an unnamed DirectState sprite with no Normal/Highlight media at all, so that substitution was ALWAYS a complete no-op — the retired AP-222's own sibling finding). **Still open (unchanged, out of this round's scope):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons remains out of scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED this round; `SetStyleSpinLabel`, still open) | The ordinal still lets a player cycle deterministically and see which slot they're on through an existing widget primitive (`UiButton.Label`) rather than adding an icon-texture pipeline this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a numbered ordinal where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | | AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1).** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. acdream's swatches show only their authored (static) DAT art regardless of which color they represent or whether the current part even has that many colors — AP-215's `.Selected` substitution covers WHICH swatch is chosen, not what each swatch itself looks like. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — sets `.Selected` only, never touches swatch appearance) | The nine swatches already reach the correct SELECTION semantics through `DatWidgetFactory`'s existing `UiButton` primitive; painting each swatch with a computed color needs either a per-swatch dynamic-color render path (new UI infrastructure this scope doesn't otherwise need) or a fallback to static art, which is what this round shipped. | A side-by-side against retail shows every swatch drawing the SAME authored art regardless of which color it represents, and swatches beyond a part's real color count staying visibly "on" instead of blanking — a real visual gap on a screen the player stares at while picking a color, not a selection-correctness gap. | `gmCGAppearancePage::DoColorSpots @0x0047d850` | | AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3) — the original row misdescribed both the retail mechanism and the acdream gap.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes — the GradCircle is authored, retail-driven *decorative art reflecting the current color*, not an input control. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback and never paints it: no `BlitAndColor`-equivalent repaint on color change, and no Eyes-blank equivalent. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page never repaints it) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint (a `Blit_Multiply` composite against `m_pGradGraphic`/`m_pGradPlug`, not a click handler) is separate follow-up work with no decomp citation yet for the composite art assets. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color (and never blanking for Eyes) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | | AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | -| AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 2fb92a5a..73f0bd66 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1,5 +1,20 @@ # Campaign CC connected gate — round 1 findings (2026-08-16) +**MILESTONE (2026-08-16, post-Batch-A build `1.0.2-cc.g`): the user +completed the FIRST LIVE CHARACTER CREATE from acdream against local ACE — +launcher → character select → Create → six pages → name → Finish → real +character created. USER-CONFIRMED: "Yes i could now create a char." The +create flow's core path is live; the round continues for visual parity +(Batches B-D) and the remaining script checks (rejection dialogs, +log-straight-in confirmation, credit/randomize/exit warnings).** + +**Batch B (selection state media + label state) is CODE-COMPLETE +2026-08-16, pending the user's visual gate.** GF-1, GF-8, GF-9, GF-10, +GF-11b, and GF-11c are fixed — see each entry's own FIXED note below. +Fixture + live-DAT tests only this round (no graphical client launch); +App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Register: +AP-222 RETIRED, AP-215 NARROWED (item 1 retired, item 2 stays open). + User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, windowed). Screenshots: retail Heritage, acdream Heritage, retail Profession. The user's side-by-side retail reports are AXIOMS @@ -9,12 +24,31 @@ ISSUES.md; this doc is the six-page batch. ## Functional (blocking or behavior-dead) -- **GF-1 Heritage selection dead/unmarked.** Clicking a heritage row does - not light its radio dot (retail: orange lit dot on the selected row — - screenshot 1). Unclear whether the click dispatches at all (the - description text that would confirm is itself broken, GF-2). ALSO: the - open-roll's own rolled heritage shows NO lit dot on entry — every dot - dark in the acdream screenshot. +- **GF-1 Heritage selection dead/unmarked — FIXED (Campaign CC gate round + 1, Batch B).** Root cause: retail authors a custom radio-selection state + pair (`RetailUiStateIds.Unselected`/`Selected`, `0x10000016`/ + `0x10000017`) on the heritage row (property-only state descriptors, no + media) with the actual art on a single stateful CHILD (the dot, + `0x100003C0`, media `0x06006E35`/`0x06006E21`, live-DAT-probe-confirmed). + `UiButton.AddAvailableStates` only recognized the standard Normal/ + Highlight/Ghosted name space, so `_availableStates` never admitted the + custom pair and `.Selected` committed nothing (probe-verified before the + fix: `Selected=true` left `ActiveState=="Unselected"`, while the raw + `TrySetRetailState(0x10000017)` already worked). Fixed by teaching + `UiButton` to detect the authored pair (`HasStateMedia("Unselected") && + HasStateMedia("Selected")`) at construction and bypass the standard + state machine for it — `.Selected` now routes directly to + `RetailUiStateIds.Selected`/`Unselected`, additive and gated on the + pair's presence, so every OTHER button's Normal/Highlight path is + byte-identical. The SAME fix also lights the Profession template icon + (`0x100003D9`), the Appearance Face/Clothes sub-tabs (GF-8, below), and + the gender buttons (whose media lives directly on the button, not a + child — the OTHER shape this fix covers). The open-roll's own + no-lit-dot-on-entry symptom shares this same root: `CharacterCreationHeritagePage.Refresh` + already sets `button.Selected = heritageId == snapshot.HeritageId` for + every row on every refresh (including the first one after open), so the + same `.Selected`-was-a-no-op bug silently ate the initial roll's own dot + too — this fix closes both halves of GF-1 with the same change. - **GF-5 Skills page empty — FIXED (Campaign CC gate round 1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows` resolving `Templates[0]` (retail's own 3-child bucket-HEADER row, @@ -33,10 +67,25 @@ ISSUES.md; this doc is the six-page batch. fully retired — the flat-list-vs-four-bucket half stays). The credits- caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is UNCHANGED — Batch C's scope. -- **GF-9 Appearance color swatches do nothing observable.** Clicking a - color produces no visible change (model recolor absent). Could be a dead - dispatch or could be working-but-invisible (AP-216 authored-art swatches - + a recolor that fails); investigate, don't guess. +- **GF-9 Appearance color swatches do nothing observable — FIXED (Campaign + CC gate round 1, Batch B).** Root cause confirmed as working-but- + invisible, not a dead dispatch: the `SelectColor`/`SetAppearanceIndex` + click path was already intact end to end (unchanged by this fix). The + swatch buttons themselves author ONLY an unnamed DirectState sprite — + live-DAT-probe-confirmed NO Normal/Highlight media at all — so the + existing `swatch.Selected = ...` highlight assignment in + `RefreshColorAndShadeControls` was a permanent no-op; nothing could ever + have shown a click's effect. Retail's REAL feedback mechanism is nine + separate companion overlay elements (`0x10000318`-`0x10000320`, + `CharacterCreationAppearancePage.SwatchOverlayIds`, live-DAT-confirmed + siblings of the swatches under the color-wheel container `0x100003B9`, + index-paired 1:1 with `SwatchIds`) that retail's `SetColor @0x0047DD50` + shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — + cross-confirmed against `gmCGAppearancePage::InitializePage`'s own + swatch/overlay id-pair table (`@0x004800ff-00480164`). Fixed by wiring + exactly one overlay visible per part, tracking the current part's + selected color index; retires AP-215's swatch-selection substitution + (item 1 — the icon-vs-ordinal item 2 stays open). - **GF-11a Town description text does not change** when switching towns. - **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED (Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat @@ -113,18 +162,62 @@ ISSUES.md; this doc is the six-page batch. per-attribute name labels (Strength…Self), Health/Stamina/Mana labels + values. Sliders and template selection themselves WORK. - **GF-6 Appearance spin captions are numbers,** not part names - ("Hair Style", "Eyes", …). Known rows AP-215/AP-218 — the gate promotes - them to must-port. + ("Hair Style", "Eyes", …). Known rows AP-215 (item 2 — item 1, the + swatch-selection substitution, RETIRED at Batch B/GF-9)/AP-218 — the + gate promotes them to must-port. - **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14); retail's chargen 3D view shows a scenic backdrop. (The Heritage-page preview area shows terrain in BOTH clients — establish from the decomp what actually renders behind the model per page/view.) -- **GF-8 Appearance Face/Clothes sub-tab selection unmarked** (AP-222 - family, promoted by the gate). -- **GF-10 Zoom buttons show identical art** whichever is pushed. -- **GF-11b Town selected marker does not turn white** (button highlights, - but retail's selected-town graphic swaps to white). -- **GF-11c Town names misaligned on the map** vs retail. +- **GF-8 Appearance Face/Clothes sub-tab selection unmarked — FIXED + (Campaign CC gate round 1, Batch B).** Same root and same fix as GF-1: + the Face (`0x100003A9`)/Clothes (`0x100003AA`) sub-tab buttons author + the identical custom Unselected/Selected radio-pair shape (media on a + stateful icon child, `0x100002E9`, live-DAT-probe-confirmed) — not the + AP-222 family as originally suspected (AP-222 turned out to be a + DIFFERENT mechanism, the per-state label color/outline gap fixed + alongside GF-11b below). `UiButton`'s custom-selection-pair bypass + fixes both in one change. +- **GF-10 Zoom buttons show identical art — FIXED (Campaign CC gate round + 1, Batch B).** Pure wiring gap, not a widget mechanism problem — both + zoom buttons already author a standard Normal/Highlight(/rollover) pair + (live-DAT-probe-confirmed). `gmCGAppearancePage::ZoomIn @0x0047CF00` + (`@0x0047d005/0x0047d00f`) ends `ZoomInButton->SetState(6)` (Highlight), + `ZoomOutButton->SetState(1)` (Normal); `ZoomOut @0x0047D050` mirrors. + `CharacterCreationAppearancePage`'s click handlers only ever called + `PreviewControl.ZoomIn()/ZoomOut()`, never touching either button's + state — fixed to set the mutual-exclusive pair on every click. Re- + derived the INITIAL state from `InitializePage @0x0047fdd0-0048032e`: + `m_bZoomedIn = 0` is set at construction, but NO explicit initial + `SetState` call exists for either zoom button anywhere in + `InitializePage` — both start at their DAT-authored "Normal" default + until the first real zoom click; this port does not force an initial + Highlight either. +- **GF-11b Town selected marker does not turn white — FIXED (Campaign CC + gate round 1, Batch B).** The marker PIN art itself already swapped + correctly (the town button's own Normal/Highlight state machine was + never broken — its marker child, `0x1000040C`, authors real Highlight + media). What was missing: retail ALSO recolors the town NAME caption + (a lifted Type-12 child, id collides with the page-level description + panel's own id `0x10000409` in the installed dat — two distinct + elements in two distinct subtrees, harmless for the per-button lift) + from gold (218,167,85) to white (255,255,255) on selection, live-DAT- + measured. `DatWidgetFactory.BuildButton` lifted the caption's font + COLOR once at build time with no per-state override. Same root and fix + as AP-222 (below): per-state label color/outline, applied off the + REQUESTED retail state id. +- **GF-11c Town names misaligned on the map — FIXED (Campaign CC gate + round 1, Batch B).** The per-button caption's own authored rect + (`(0,4,100,37)`, Center-justified, live-DAT-measured) was being + discarded in favor of a Left-aligned offset computed from the marker + FACE's rect (`face.X + face.Width + 4`) — correct for the heritage/ + template/Face-Clothes row family (label authored DIRECTLY on the + button, beside a single-purpose face segment) but wrong here, where a + DISTINCT Type-12 caption child was lifted with its own independent + geometry. Fixed by adding `UiButton.LabelBox`: when a distinct lifted + caption carries its own rect, the label draws within THAT box using + its own authored justify instead of the face-relative offset; every + other button (`LabelBox` null) keeps the EXACT prior draw math. - **GF-12 Missing authored gold frames** around boxes on every page (Skills/Appearance/Town/Summary called out explicitly). - **GF-14 Summary paperdoll backdrop black** (same family as GF-7); @@ -138,9 +231,19 @@ ISSUES.md; this doc is the six-page batch. decide per element from the authored DAT + decomp. 2. Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap feeding GF-2/GF-3/GF-11a/GF-14. -3. Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, +3. ~~Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, GF-10 zoom art) — the AP-222 measured mechanism (state media authored - vs applied) across widget kinds. + vs applied) across widget kinds.~~ CLOSED, split into TWO distinct + mechanisms, both fixed at Batch B: (a) GF-1/GF-8 share a genuinely + UNRECOGNIZED custom state-name pair (`UiButton` never admitted + "Unselected"/"Selected" into its available-states set at all); GF-10 + was pure wiring (the standard Normal/Highlight pair was never even + requested). (b) GF-11b turned out NOT to be a state-media gap — the + marker's own media swap already worked; the actual gap was AP-222's + real mechanism, per-state LABEL COLOR/OUTLINE (a property commit + distinct from the art/media commit, and NOT gated by the same art- + availability check `ActiveState` is). See each GF's own FIXED entry + above and the retired AP-222 / narrowed AP-215 register rows. 4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws. 5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked chargen screen.~~ CLOSED: focus/typing routing was never broken (live- diff --git a/src/AcDream.App/UI/IUiDatStateful.cs b/src/AcDream.App/UI/IUiDatStateful.cs index 7a86e9b8..da1b0437 100644 --- a/src/AcDream.App/UI/IUiDatStateful.cs +++ b/src/AcDream.App/UI/IUiDatStateful.cs @@ -24,6 +24,22 @@ public static class RetailUiStateIds public const uint LockedUi = 0x10000063u; public const uint UnlockedUi = 0x10000064u; + /// + /// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom + /// radio-selection state pair, live-DAT-probe-confirmed on the Heritage + /// row (0x100003BF), Profession template (0x100003D9), + /// Appearance Face/Clothes sub-tabs (0x100003A9/0x100003AA), + /// and gender buttons (0x100003A7/0x100003A8). Named + /// UiStateInfo.Name strings, not media file ids — the buttons + /// author their state DESCRIPTORS under these two ids, with the actual + /// per-state art living either directly on the button (gender) or on a + /// single stateful face-segment child (heritage/template/sub-tabs). + /// See 's custom-selection-pair + /// bypass in UpdateVisualState. + /// + public const uint Unselected = 0x10000016u; + public const uint Selected = 0x10000017u; + public static string StateName(uint stateId) => stateId switch { @@ -38,6 +54,8 @@ public static class RetailUiStateIds Minimized => "Minimized", LockedUi => "LockedUI", UnlockedUi => "UnlockedUI", + Unselected => "Unselected", + Selected => "Selected", _ => "", }; @@ -56,6 +74,8 @@ public static class RetailUiStateIds "Minimized" => Minimized, "LockedUI" => LockedUi, "UnlockedUI" => UnlockedUi, + "Unselected" => Unselected, + "Selected" => Selected, _ => 0u, }; return stateId != 0; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 070cd131..92b54b49 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -126,6 +126,31 @@ internal sealed class CharacterCreationAppearancePage : IDisposable 0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u, ]; + /// + /// GF-9 (Campaign CC gate round 1 Batch B): the nine Type-3 companion + /// "selected" overlay elements, one per entry at + /// the SAME index — retail gmCGAppearancePage::InitializePage's + /// own id-pair table (@0x004800ff-00480164, the switch that fills + /// m_tColorWheel[i][0x10]/[0x14]-ish offsets with the + /// swatch/overlay id pair per index) resolves the SAME nine ids this + /// array carries, in the SAME index order. SetColor (case + /// 0x0047DD50, iCurColor assignment) is retail's actual + /// click-feedback mechanism — SetVisible on the overlay at + /// iCurColor's index, NOT a state swap on the swatch itself (the + /// swatch buttons author only an unnamed DirectState sprite; measured + /// against the installed dat, they have NO Normal/Highlight media at + /// all, so was always a complete no-op + /// here — see ). Live-DAT- + /// probe-confirmed siblings of the swatches under the same color-wheel + /// container (0x100003B9), each roughly centered on its paired + /// swatch's own rect. + /// + internal static readonly uint[] SwatchOverlayIds = + [ + 0x10000318u, 0x10000319u, 0x1000031Au, 0x1000031Bu, 0x1000031Cu, + 0x1000031Du, 0x1000031Eu, 0x1000031Fu, 0x10000320u, + ]; + /// Live-DAT-measured arrow geometry, uniform across all nine /// spins (every one is 200px wide): decrement child at local /// x=[80,127), increment child at x=[127,174). Anything outside both @@ -144,6 +169,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiElement? _clothesChoices; private readonly Dictionary _spins = []; private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length]; + private readonly UiElement?[] _swatchOverlays = new UiElement?[SwatchOverlayIds.Length]; private readonly UiScrollbar? _shadeScroll; private readonly UiButton? _rotateClockwise; private readonly UiButton? _rotateCounterClockwise; @@ -209,6 +235,9 @@ internal sealed class CharacterCreationAppearancePage : IDisposable _swatches[i] = swatch; } + for (int i = 0; i < SwatchOverlayIds.Length; i++) + _swatchOverlays[i] = Find(pageRoot, SwatchOverlayIds[i]); + _shadeScroll = Find(pageRoot, ShadeScrollId); if (_shadeScroll is not null) _shadeScroll.ScalarChanged = SetShadeFromScalar; @@ -223,10 +252,33 @@ internal sealed class CharacterCreationAppearancePage : IDisposable _rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise(); _zoomIn = Find(pageRoot, ZoomInId); if (_zoomIn is not null) - _zoomIn.OnClick = () => PreviewControl?.ZoomIn(); + _zoomIn.OnClick = () => + { + PreviewControl?.ZoomIn(); + // GF-10: gmCGAppearancePage::ZoomIn @0x0047CF00 + // (@0x0047d005/0x0047d00f) ends ZoomInButton->SetState(6) + // (Highlight), ZoomOutButton->SetState(1) (Normal) — a + // mutual-exclusive pair. Re-derived from InitializePage + // @0x0047fdd0-0048032e (m_bZoomedIn = 0 at construction, + // @0x004802c3): NO explicit initial SetState call exists + // for either button, so both start at their DAT-authored + // "Normal" default (live-DAT-probe-confirmed) until the + // first real zoom click — this port does not force an + // initial Highlight. + _zoomIn.TrySetRetailState(UiButtonStateMachine.Highlight); + _zoomOut?.TrySetRetailState(UiButtonStateMachine.Normal); + }; _zoomOut = Find(pageRoot, ZoomOutId); if (_zoomOut is not null) - _zoomOut.OnClick = () => PreviewControl?.ZoomOut(); + _zoomOut.OnClick = () => + { + PreviewControl?.ZoomOut(); + // GF-10: gmCGAppearancePage::ZoomOut @0x0047D050 + // (@0x0047d140/0x0047d14a) mirrors ZoomIn — ZoomOutButton + // -> Highlight(6), ZoomInButton -> Normal(1). + _zoomOut.TrySetRetailState(UiButtonStateMachine.Highlight); + _zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal); + }; ApplyChoiceVisibility(); } @@ -608,12 +660,21 @@ internal sealed class CharacterCreationAppearancePage : IDisposable : UiButtonStateMachine.Normal); } + // GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch + // click feedback is the companion overlay's visibility (SetColor + // @0x0047DD50 -> m_tColorWheel[...][0x10][iCurColor*7]->SetVisible), + // not a state swap on the swatch button — measured against the + // installed dat, the nine swatches author only a DirectState sprite + // with no Normal/Highlight media at all, so a prior + // swatch.Selected assignment here was a permanent no-op (see + // SwatchOverlayIds' own doc comment). Exactly one overlay is + // visible: the one at the current part's own selected color index. ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart); uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); - for (int i = 0; i < _swatches.Length; i++) + for (int i = 0; i < _swatchOverlays.Length; i++) { - if (_swatches[i] is { } swatch) - swatch.Selected = colorSlot is not null && currentColor == (uint)i; + if (_swatchOverlays[i] is { } overlay) + overlay.Visible = colorSlot is not null && currentColor == (uint)i; } ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index eb74843d..bf4df604 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -878,8 +878,31 @@ public static class DatWidgetFactory button.FaceTop = face.Y; button.FaceWidth = face.Width; button.FaceHeight = face.Height; - button.LabelAlign = UiButton.LabelAlignment.Left; - button.LabelOffsetX = face.X + face.Width + 4f; + + if (!ReferenceEquals(labelInfo, info)) + { + // GF-11c (Campaign CC gate round 1 Batch B): a DISTINCT + // Type-12 caption was lifted (e.g. the Town page's per- + // marker name label, 0x10000409 under each town button — + // live-DAT-probe-confirmed authored rect + Center justify, + // independent of the marker face's own geometry) — honor + // ITS OWN authored rect/justify instead of the face- + // relative offset below, which is only correct when the + // label text is authored DIRECTLY on the button itself, + // immediately beside a single-purpose face segment (the + // heritage/template/Face-Clothes sub-tab row family — + // still handled by the else-branch two lines down, since + // ReferenceEquals(labelInfo, info) is true there). + button.LabelBox = (labelInfo.X, labelInfo.Y, labelInfo.Width, labelInfo.Height); + button.LabelAlign = labelInfo.HJustify == HJustify.Left + ? UiButton.LabelAlignment.Left + : UiButton.LabelAlignment.Center; + } + else + { + button.LabelAlign = UiButton.LabelAlignment.Left; + button.LabelOffsetX = face.X + face.Width + 4f; + } } else if (labelInfo.HJustify == HJustify.Left) { @@ -901,6 +924,15 @@ public static class DatWidgetFactory button.LabelOffsetX = labelInfo.X; } + // AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label + // color/outline (dat properties 0x1B/0x21 authored PER STATE on the + // label-bearing element — the Appearance spins' own states, or the + // Town caption child's states) — additive, only non-null when the + // authored dat genuinely carries more than one distinct value. + button.SetPerStateLabelStyle( + ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu), + ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u)); + return button; } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index e1204f9e..606e19e1 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Numerics; using AcDream.App.UI; @@ -673,4 +674,65 @@ public static class ElementReader }) .ToArray(); } + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color + /// property (0x1B FontColor's Array-tolerant shape, same unwrap as + /// ) for EVERY state itself authors, keyed by retail numeric state id. + /// Returns null unless at least two states resolve to GENUINELY + /// DIFFERENT colors — the overwhelming majority of elements author one + /// color for every state (or none at all), and for those this returns + /// null so the caller keeps its existing single-default-color behavior + /// untouched. Only elements that really do recolor per state (the + /// Appearance spins' Highlight brightening, the Town buttons' Normal- + /// to-white caption swap) get a non-null map. + /// + internal static IReadOnlyDictionary? BuildPerStateColorMap( + ElementInfo info, uint propertyId) + { + Dictionary? map = null; + foreach (uint stateId in info.States.Keys) + { + if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId)) + continue; + + UiPropertyValue? colorValue = value.Kind == UiPropertyKind.Color + ? value + : value.Kind == UiPropertyKind.Array + && value.ArrayValue.Count > 0 + && value.ArrayValue[0].Kind == UiPropertyKind.Color + ? value.ArrayValue[0] + : null; + if (colorValue is null) + continue; + + UiColorValue c = colorValue.ColorValue; + float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f; + (map ??= new Dictionary())[stateId] = + new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha); + } + + return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null; + } + + /// + /// AP-222 counterpart of for a bool + /// property (0x21 Outline) — same "null unless genuinely per-state" + /// gating. + /// + internal static IReadOnlyDictionary? BuildPerStateBoolMap( + ElementInfo info, uint propertyId) + { + Dictionary? map = null; + foreach (uint stateId in info.States.Keys) + { + if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId) + || value.Kind != UiPropertyKind.Bool) + continue; + (map ??= new Dictionary())[stateId] = value.BoolValue; + } + + return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null; + } } diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index b748894b..41146914 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -37,6 +37,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private readonly FaceSegment[] _faceSegments; private readonly Func _resolve; private readonly HashSet _availableStates = new(); + private readonly bool _hasCustomSelectionPair; + private IReadOnlyDictionary? _stateLabelColors; + private IReadOnlyDictionary? _stateLabelOutlines; private bool _pressed; private bool _pointerOver; private bool _selected; @@ -157,6 +160,25 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots. public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center; + /// + /// GF-11c (Campaign CC gate round 1 Batch B): optional authored label + /// rectangle, LOCAL to this button. When a caption is LIFTED from a + /// DISTINCT Type-12 child that carries its own independent rect (e.g. + /// the Town page's per-marker name label, positioned below/beside its + /// marker rather than immediately right of it), + /// draws the label within THIS box using its own authored geometry + /// instead of the FaceLeft-derived offset / full-button-width centering + /// the ordinary case uses (label authored directly on the button, right + /// beside a single-purpose face segment — the heritage/template/Face- + /// Clothes row family, where the current face-relative math is already + /// correct). Null (default, every pre-existing button) preserves the + /// EXACT prior draw math — still adds + /// to the button's own local origin, and + /// still centers within the whole + /// button width/height. + /// + public (float X, float Y, float Width, float Height)? LabelBox { get; set; } + /// Label horizontal alignment options. public enum LabelAlignment { Center, Left } @@ -330,6 +352,21 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful foreach (FaceSegment segment in _faceSegments) AddAvailableStates(segment.Info); + // Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom + // "Unselected"/"Selected" radio-selection state pair + // (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is + // authored as STATE DESCRIPTORS whose names UiButtonStateMachine's + // Normal/Highlight machine doesn't recognize — the standard + // AddAvailableStates loop above never admits them, so the ordinary + // RequestedState()-driven UpdateVisualState can never select them + // (measured: Selected=true committed nothing against the installed + // dat before this fix). HasStateMedia already checks the same media + // presence (face-segment child OR the button's own StateMedia) used + // everywhere else in this class, so this reuses that exact + // detection rather than adding a new one. + _hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected)) + && HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected)); + ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle; RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover; HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick; @@ -402,10 +439,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (Label is { Length: > 0 } label && LabelFont is { } lf) { + // GF-11c: LabelBox null (every pre-existing button) reduces boxX/ + // boxY to 0 and boxWidth/boxHeight to the button's own Width/ + // Height — byte-identical to the prior unconditional math. + float boxX = LabelBox?.X ?? 0f; + float boxY = LabelBox?.Y ?? 0f; + float boxWidth = LabelBox?.Width ?? Width; + float boxHeight = LabelBox?.Height ?? Height; float tx = LabelAlign == LabelAlignment.Left - ? LabelOffsetX - : (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default) - float ty = (Height - lf.LineHeight) * 0.5f; + ? boxX + LabelOffsetX + : boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default) + float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f; ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor); } @@ -638,13 +682,77 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private void UpdateVisualState() { - uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput( - Disabled: !Enabled, - Selected: _selected, - RolloverEnabled: RolloverEnabled, - Pressed: _pressed, - PointerOver: _pointerOver)); - if (_availableStates.Contains(requested)) + uint requested = ComputeRequestedStateId(); + if (_hasCustomSelectionPair) + { + // gmCGHeritagePage::Update @0x00483219-0x0048372D (and the + // mirrored template/sub-tab/gender call sites): retail sets + // this pair directly by SELECTION, not through the ordinary + // Normal/Highlight/rollover/pressed machine — these buttons + // never author rollover or pressed media for the pair, so + // there is nothing faithful to compute beyond selected-or-not. + ActiveState = RetailUiStateIds.StateName(requested); + } + else if (_availableStates.Contains(requested)) + { ActiveState = UiButtonStateMachine.StateName(requested); + } + + // AP-222: apply the per-state label style off the REQUESTED id, not + // the (possibly art-gated) committed ActiveState — retail's own + // SetState(6) commits the state's PROPERTIES (including text color) + // unconditionally; only the SPRITE draw silently no-ops when a + // state has no media (this class's own #382 comment on + // TrySetRetailState documents the same distinction). The + // Appearance spins' current-part highlight is exactly this case: + // _availableStates never contains Highlight (their arrow face + // segments carry no Highlight art), so ActiveState stays "Normal" + // forever, but the spin's OWN label color must still swap. + ApplyPerStateLabelStyle(requested); + } + + private uint ComputeRequestedStateId() + => _hasCustomSelectionPair + ? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected) + : UiButtonStateMachine.RequestedState(new UiButtonVisualInput( + Disabled: !Enabled, + Selected: _selected, + RolloverEnabled: RolloverEnabled, + Pressed: _pressed, + PointerOver: _pointerOver)); + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per- + /// RETAIL-STATE label color/outline override, additive over the single + /// default / lifted once at + /// construction. Set by ONLY when + /// the authored dat genuinely carries more than one distinct value + /// across this button's (or its lifted caption child's) own states — + /// e.g. the Appearance spins' Highlight-state gold brightening + /// (dat properties 0x1B/0x21, live-DAT-measured + /// 218,167,85 -> 255,221,131 plus outline off -> on) or the Town + /// buttons' Normal-to-white caption swap (218,167,85 -> 255,255,255). + /// A button with a single authored color (the overwhelming majority) + /// never calls this, so / + /// keep behaving exactly as before — including every existing external + /// post-construction assignment (e.g. ChatWindowController's Send + /// caption, PaperdollController's Slots label), none of which + /// author a second distinct per-state color. + /// + internal void SetPerStateLabelStyle( + IReadOnlyDictionary? colors, + IReadOnlyDictionary? outlines) + { + _stateLabelColors = colors; + _stateLabelOutlines = outlines; + ApplyPerStateLabelStyle(ComputeRequestedStateId()); + } + + private void ApplyPerStateLabelStyle(uint requestedStateId) + { + if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color)) + LabelColor = color; + if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline)) + Outline = outline; } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 7d1db271..88cb63ff 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1,5 +1,6 @@ using System.IO; using System.Linq; +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Content; @@ -278,6 +279,239 @@ public sealed class CharacterCreationLiveDatTests UiElement.FindDescendant(townRoot, 0x10000409u)); } + /// + /// GF-11b/GF-11c (Campaign CC gate round 1 Batch B). Live-DAT-measured: + /// each town button's marker (0x1000040D's own child + /// 0x1000040C) and its per-button name caption both carry the + /// SAME numeric id 0x10000409 as the page-level description + /// panel found by the sibling test above — a genuine id collision in + /// the installed dat between two DIFFERENT elements in DIFFERENT + /// subtrees (harmless for DatWidgetFactory.BuildButton's lift, + /// which walks the button's OWN ElementInfo.Children list rather + /// than resolving by a global id lookup — but it means this test + /// verifies the BUILT BUTTON's own / + /// /, + /// not a second FindDescendant call, which would ambiguously + /// return the unrelated page-level panel). Pins: the caption's own + /// authored rect (0,4,100,37) survives instead of being overwritten by + /// the marker-face-relative offset (GF-11c), and its color swaps + /// Normal (218,167,85) -> Highlight/white (255,255,255) on selection + /// (GF-11b). + /// + [InstalledDatFact] + public void TownPage_HoltburgButton_CaptionHonorsOwnRectAndRecolorsWhiteOnSelection() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement townRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.TownPageElementId)); + UiButton holtburg = AssertButton(townRoot, 0x1000040Du); + + Assert.Equal("Holtburg", holtburg.Label); + Assert.Equal(UiButton.LabelAlignment.Center, holtburg.LabelAlign); + Assert.Equal((0f, 4f, 100f, 37f), holtburg.LabelBox); + + holtburg.Selected = false; + Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), holtburg.LabelColor); + + holtburg.Selected = true; + Assert.Equal(new Vector4(1f, 1f, 1f, 1f), holtburg.LabelColor); + } + + /// + /// GF-1 (Campaign CC gate round 1 Batch B). Live-DAT-measured: the + /// Heritage row (0x100003BFu, Aluvian) authors retail's custom + /// "Unselected"/"Selected" radio-pair state DESCRIPTORS directly on the + /// row (property-only, no media), with the actual per-state art on its + /// single stateful dot child (0x100003C0). Before this fix, + /// committed nothing here. + /// + [InstalledDatFact] + public void HeritagePage_Row_SelectedTogglesTheAuthoredRadioDotState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement heritageRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + UiButton aluvian = AssertButton(heritageRoot, 0x100003BFu); + + Assert.Equal("Unselected", aluvian.ActiveState); + aluvian.Selected = true; + Assert.Equal("Selected", aluvian.ActiveState); + Assert.Equal(RetailUiStateIds.Selected, aluvian.ActiveRetailStateId); + aluvian.Selected = false; + Assert.Equal("Unselected", aluvian.ActiveState); + } + + /// + /// GF-1 counterpart: the Profession template row (0x100003D9u, + /// Custom/Adventurer) authors the identical custom radio-pair shape on + /// its own icon child (0x100002E9). + /// + [InstalledDatFact] + public void ProfessionPage_TemplateButton_SelectedTogglesTheAuthoredIconState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement professionRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProfessionPageElementId)); + UiButton template = AssertButton(professionRoot, 0x100003D9u); + + Assert.Equal("Unselected", template.ActiveState); + template.Selected = true; + Assert.Equal("Selected", template.ActiveState); + template.Selected = false; + Assert.Equal("Unselected", template.ActiveState); + } + + /// + /// GF-8: the Appearance page's Face/Clothes sub-tab buttons + /// (0x100003A9u/0x100003AAu) author the SAME custom + /// radio-pair shape on their own icon child (0x100002E9) — same + /// mechanism as the heritage/template rows, different page. + /// + [InstalledDatFact] + public void AppearancePage_FaceClothesSubTabButtons_SelectedTogglesTheAuthoredState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint buttonId in new[] + { + CharacterCreationAppearancePage.FaceButtonId, + CharacterCreationAppearancePage.ClothesButtonId, + }) + { + UiButton button = AssertButton(appearanceRoot, buttonId); + Assert.Equal("Unselected", button.ActiveState); + button.Selected = true; + Assert.Equal("Selected", button.ActiveState); + button.Selected = false; + Assert.Equal("Unselected", button.ActiveState); + } + } + + /// + /// GF-1 family, gender-button shape: 0x100003A7u/ + /// 0x100003A8u author the custom Unselected/Selected media + /// DIRECTLY on the button's own StateMedia (no separate face-segment + /// child) — live-DAT-measured, distinct from the heritage/template/ + /// sub-tab family above. Exercises 's OTHER + /// custom-selection-pair code path (media on the button itself, so + /// info.StateMedia.Count != 0 and no face child is ever + /// computed). + /// + [InstalledDatFact] + public void AppearancePage_GenderButtons_SelectedTogglesTheAuthoredState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint buttonId in new[] + { + CharacterCreationAppearancePage.FemaleButtonId, + CharacterCreationAppearancePage.MaleButtonId, + }) + { + UiButton button = AssertButton(appearanceRoot, buttonId); + Assert.Equal("Unselected", button.ActiveState); + button.Selected = true; + Assert.Equal("Selected", button.ActiveState); + button.Selected = false; + Assert.Equal("Unselected", button.ActiveState); + } + } + + /// + /// GF-9 (Campaign CC gate round 1 Batch B). Live-DAT-measured: all nine + /// companion overlay elements (SwatchOverlayIds) resolve as + /// siblings of the swatches under the color-wheel container — retail's + /// ACTUAL click-feedback mechanism (SetColor's + /// m_tColorWheel[...][0x10][iCurColor*7]->SetVisible), not a + /// state swap on the swatch buttons themselves (which author only an + /// unnamed DirectState sprite — no Normal/Highlight media at all). + /// + [InstalledDatFact] + public void AppearancePage_SwatchOverlays_AllNinePresent() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds) + { + UiElement overlay = Assert.IsAssignableFrom( + UiElement.FindDescendant(appearanceRoot, overlayId)); + // Retail's overlay ring starts hidden — SetColor only shows the + // one at the current color index; nothing is selected before + // any color choice runs. + Assert.True(overlay.Visible); + } + } + + /// + /// GF-10 (Campaign CC gate round 1 Batch B). Live-DAT-measured: both + /// zoom buttons author a STANDARD Normal/Highlight(/rollover) pair — + /// unlike the custom radio-pair family above, this is pure wiring + /// ('s click handlers), not + /// a new UiButton mechanism. + /// + [InstalledDatFact] + public void AppearancePage_ZoomButtons_AuthorStandardNormalHighlightPair() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint buttonId in new[] + { + CharacterCreationAppearancePage.ZoomInId, + CharacterCreationAppearancePage.ZoomOutId, + }) + { + UiButton button = AssertButton(appearanceRoot, buttonId); + Assert.Equal("Normal", button.ActiveState); + Assert.True(button.TrySetRetailState(UiButtonStateMachine.Highlight)); + Assert.Equal("Highlight", button.ActiveState); + Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal)); + Assert.Equal("Normal", button.ActiveState); + } + } + /// The exit-warning + all per-heritage/per-town DAT string /// keys this slice cites actually resolve in the installed table. /// @@ -401,6 +635,25 @@ public sealed class CharacterCreationLiveDatTests spin.TrySetRetailState(UiButtonStateMachine.Highlight), $"spin 0x{spinId:X8} must accept a Highlight state request."); Assert.Equal("Normal", spin.ActiveState); + + // AP-222 CORRECTED + RESOLVED (Campaign CC gate round 1 Batch B): + // the art half of the "no-op" stays a genuine no-op (ActiveState + // pinned to "Normal" above, unchanged) — retail's own spin art + // authors no Highlight media either, matching acdream. But the + // current-part highlight is NOT presentation-dead: retail's + // SetState(6) also recolors the spin's caption text (dat + // property 0x1B), live-DAT-measured 218,167,85 (Normal) -> + // 255,221,131 (Highlight), plus outline off -> on (property + // 0x21). DatWidgetFactory.BuildButton wires this per-state style + // unconditionally, so it is already active on `spin` from + // TrySetRetailState(Highlight) above, independent of whether the + // page has assigned a Label string yet. + Assert.Equal(new Vector4(255f / 255f, 221f / 255f, 131f / 255f, 1f), spin.LabelColor); + Assert.True(spin.Outline, $"spin 0x{spinId:X8} must outline its label in the Highlight state."); + + Assert.True(spin.TrySetRetailState(UiButtonStateMachine.Normal)); + Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), spin.LabelColor); + Assert.False(spin.Outline, $"spin 0x{spinId:X8} must not outline its label in the Normal state."); } // Every color-wheel-family id resolves through EXISTING diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 462b4622..7d949070 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -785,6 +785,52 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount); } + /// + /// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch click + /// feedback — exactly one companion overlay visible, tracking the + /// current part's own selected color index (SetColor's + /// m_tColorWheel[...][0x10][iCurColor*7]->SetVisible). Drives + /// the snapshot directly (the fake binding only records what a click + /// SENDS, it doesn't feed it back) to exercise + /// RefreshColorAndShadeControls's own overlay loop end to end. + /// + [Fact] + public void AppearanceSwatchOverlays_ExactlyOneVisible_TrackingTheCurrentPartsColorIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + // Part defaults to Hair on construction — no extra click needed. + + UiElement[] overlays = [.. CharacterCreationAppearancePage.SwatchOverlayIds + .Select(environment.Page)]; + + // No color selected yet (Unset) -> every overlay hidden. + Assert.All(overlays, overlay => Assert.False(overlay.Visible)); + + RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with + { + Revision = snapshot.Revision + 1, + Appearance = snapshot.Appearance with { HairColor = 1u }, + }; + environment.Controller.Tick(); + + for (int i = 0; i < overlays.Length; i++) + Assert.Equal(i == 1, overlays[i].Visible); + + snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with + { + Revision = snapshot.Revision + 1, + Appearance = snapshot.Appearance with { HairColor = 2u }, + }; + environment.Controller.Tick(); + + for (int i = 0; i < overlays.Length; i++) + Assert.Equal(i == 2, overlays[i].Visible); + } + [Fact] public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart() { @@ -848,6 +894,48 @@ public sealed class CharacterCreationUiControllerTests environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); } + /// + /// GF-10 (Campaign CC gate round 1 Batch B): ports + /// gmCGAppearancePage::ZoomIn @0x0047CF00 + /// (@0x0047d005/0x0047d00f: ZoomInButton -> Highlight(6), + /// ZoomOutButton -> Normal(1)) and its ZoomOut mirror + /// (@0x0047D050, @0x0047d140/0x0047d14a). Both buttons + /// start at their DAT-authored "Normal" default — re-derived from + /// InitializePage @0x0047fdd0-0048032e: m_bZoomedIn = 0 is + /// set at construction (@0x004802c3) but NO explicit initial + /// SetState call exists for either zoom button anywhere in + /// InitializePage, so this port does not force one either. + /// + [Fact] + public void AppearanceZoomButtons_ClickPath_TogglesMutualExclusiveHighlightPair() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + var preview = new FakeChargenPreviewControl(); + environment.Controller.AppearancePreviewControl = preview; + + UiButton zoomIn = environment.Button(CharacterCreationAppearancePage.ZoomInId); + UiButton zoomOut = environment.Button(CharacterCreationAppearancePage.ZoomOutId); + + Assert.Equal("Normal", zoomIn.ActiveState); + Assert.Equal("Normal", zoomOut.ActiveState); + + zoomIn.OnClick!(); + Assert.Equal("Highlight", zoomIn.ActiveState); + Assert.Equal("Normal", zoomOut.ActiveState); + + zoomOut.OnClick!(); + Assert.Equal("Normal", zoomIn.ActiveState); + Assert.Equal("Highlight", zoomOut.ActiveState); + + // Re-asserting the SAME direction is idempotent (retail's own early- + // return branch when already zoomed in/out — this port doesn't + // track m_bZoomedIn, but the RESULT is identical either way). + zoomOut.OnClick!(); + Assert.Equal("Normal", zoomIn.ActiveState); + Assert.Equal("Highlight", zoomOut.ActiveState); + } + private static void SelectAluvianMale(EnvironmentHarness environment) { environment.Runtime.SelectHeritageDirect(AluvianId); @@ -2151,6 +2239,11 @@ public sealed class CharacterCreationUiControllerTests foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) page.Children.Add(ButtonInfo(swatchId)); + // GF-9: the nine companion overlay elements, live-DAT-measured as + // plain Type-3 siblings of the swatches under the color-wheel + // container. + foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds) + page.Children.Add(ContainerInfo(overlayId)); page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId)); page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId)); @@ -2166,12 +2259,27 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId)); page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId)); - page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId)); - page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); + // GF-10: unlike the plain ButtonInfo() used above, the zoom buttons + // need REAL Normal/Highlight media so AppearanceZoomButtons_ + // ClickPath_TogglesMutualExclusiveHighlightPair can observe the + // actual mutual-exclusive state swap through TrySetRetailState — + // live-DAT-measured shape (both start "Normal", both author + // Highlight/rollover media). + page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId)); + page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); return page; } + private static ElementInfo ZoomButtonInfo(uint id) + { + var info = new ElementInfo { Id = id, Type = 1u, Width = 81f, Height = 38f }; + info.StateMedia["Normal"] = (0x06004D55u, 1); + info.StateMedia["Highlight"] = (0x06004D56u, 1); + info.DefaultStateName = "Normal"; + return info; + } + private static ElementInfo SpinInfo(uint id) { var spin = new ElementInfo diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 72880d99..9c428648 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -366,6 +366,96 @@ public class DatWidgetFactoryTests Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign); } + /// + /// GF-11c (Campaign CC gate round 1 Batch B): the Town page's per-marker + /// caption shape (live-DAT-measured on 0x1000040D/0x10000409) — a + /// single stateful face child (the marker/pin, Normal/Highlight media) + /// PLUS a DISTINCT Type-12 caption child with its own authored rect and + /// Center justify, positioned independently of the marker (e.g. below + /// or above it, not necessarily beside it). Before this fix, the + /// caption's own rect/justify was discarded in favor of a Left-aligned + /// offset computed from the FACE rect — correct only for the heritage/ + /// template row family below, where the label is authored directly on + /// the button itself (see the companion regression test). + /// + [Fact] + public void BuildButton_SingleFaceChild_LiftedCaptionWithOwnRect_HonorsLabelBoxNotFaceOffset() + { + uint stringId = 555u; + var info = new ElementInfo { Type = 1, Width = 106, Height = 80 }; + info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" }; + info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" }; + + var caption = new ElementInfo + { + Type = 12, + X = 0, + Y = 4, + Width = 100, + Height = 37, + HJustify = HJustify.Center, + }; + caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0), + }; + info.Children.Add(caption); + + var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 }; + marker.StateMedia["Normal"] = (0x06004D60u, 1); + marker.StateMedia["Highlight"] = (0x06004D61u, 1); + info.Children.Add(marker); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == stringId ? "Holtburg" : null)); + + Assert.Equal("Holtburg", button.Label); + Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign); + Assert.Equal((0f, 4f, 100f, 37f), button.LabelBox); + // The face geometry is still captured for the marker's own draw — + // just no longer used to derive the label's position. + Assert.Equal((36f, 36f, 38f, 38f), (button.FaceLeft, button.FaceTop, button.FaceWidth, button.FaceHeight)); + } + + /// + /// Regression companion: the heritage/template/Face-Clothes row shape — + /// the label string is authored DIRECTLY on the button (no distinct + /// Type-12 child), beside a single stateful face child (the radio dot). + /// This is the case the FACE-relative Left-aligned offset math IS + /// correct for, and it must keep working exactly as before GF-11c. + /// + [Fact] + public void BuildButton_SingleFaceChild_DirectLabel_KeepsFaceRelativeOffset() + { + uint stringId = 777u; + var info = new ElementInfo { Type = 1, Width = 305, Height = 32, HJustify = HJustify.Left }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0), + }; + info.States[RetailUiStateIds.Unselected] = new UiStateInfo { Id = RetailUiStateIds.Unselected, Name = "Unselected" }; + info.States[RetailUiStateIds.Selected] = new UiStateInfo { Id = RetailUiStateIds.Selected, Name = "Selected" }; + + var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 }; + dot.StateMedia["Unselected"] = (0x06006E35u, 1); + dot.StateMedia["Selected"] = (0x06006E21u, 1); + info.Children.Add(dot); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == stringId ? "Aluvian" : null)); + + Assert.Equal("Aluvian", button.Label); + Assert.Null(button.LabelBox); + Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign); + Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4 + } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 8baaaa38..e21c5849 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -249,6 +249,171 @@ public class UiButtonTests Assert.Equal(2, clicks); } + /// + /// GF-1/GF-8 (Campaign CC gate round 1 Batch B): the "gender button" + /// shape — retail's custom Unselected/Selected radio-pair media authored + /// DIRECTLY on the button's own StateMedia (no separate face-segment + /// child), live-DAT-measured on 0x100003A7/0x100003A8 (Female/Male). + /// Before this fix, .Selected committed nothing: the standard + /// AddAvailableStates loop never recognized the "Unselected"/"Selected" + /// names, so _availableStates was empty and UpdateVisualState's + /// RequestedState (which only ever returns Normal/Highlight/Ghosted ids) + /// could never match anyway. + /// + [Fact] + public void CustomSelectionPair_MediaDirectlyOnButton_SelectedTogglesActiveState() + { + var info = ButtonInfo("Unselected", "Selected"); + var b = CreateButton(info); + + Assert.Equal("Unselected", b.ActiveState); + + b.Selected = true; + Assert.Equal("Selected", b.ActiveState); + + b.Selected = false; + Assert.Equal("Unselected", b.ActiveState); + } + + /// + /// The "heritage/template/sub-tab row" shape — the parent authors the + /// Unselected/Selected state DESCRIPTORS (property bag only, no media), + /// and a single stateful child (the radio dot / icon) carries the + /// actual per-state art, matching FindStatefulFaceChildren's + /// name-overlap detection. Live-DAT-measured on the Heritage row + /// (0x100003BF, dot child 0x100003C0) and the Profession template row + /// (0x100003D9, icon child 0x100002E9). + /// + [Fact] + public void CustomSelectionPair_MediaOnFaceChild_SelectedTogglesActiveState() + { + var info = new ElementInfo { Type = 1, Width = 305, Height = 32 }; + info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo + { + Id = UiButtonStateMachine.NormalPressed, + Name = "Normal_pressed", + }; + info.States[RetailUiStateIds.Unselected] = new UiStateInfo + { + Id = RetailUiStateIds.Unselected, + Name = "Unselected", + }; + info.States[RetailUiStateIds.Selected] = new UiStateInfo + { + Id = RetailUiStateIds.Selected, + Name = "Selected", + }; + info.DefaultStateName = "Unselected"; + + var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 }; + dot.StateMedia["Unselected"] = (0x06006E35u, 1); + dot.StateMedia["Selected"] = (0x06006E21u, 1); + info.Children.Add(dot); + + // Face-child discovery (FindStatefulFaceChildren) is DatWidgetFactory's + // job, not UiButton's own constructor — go through the real factory + // path so this fixture matches production exactly (raw CreateButton + // below bypasses that discovery entirely). + var b = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.Equal("Unselected", b.ActiveState); + + b.Selected = true; + Assert.Equal("Selected", b.ActiveState); + Assert.Equal(RetailUiStateIds.Selected, b.ActiveRetailStateId); + + b.Selected = false; + Assert.Equal("Unselected", b.ActiveState); + } + + /// + /// Regression pin: a STANDARD ToggleBehavior button (no Unselected/ + /// Selected states authored at all — the overwhelming majority of + /// buttons, including every pre-existing ToggleBehavior consumer) keeps + /// behaving exactly as before the custom-pair bypass was added. + /// + [Fact] + public void CustomSelectionPair_Absent_StandardToggleBehaviorUnchanged() + { + var info = ButtonInfo("Normal", "Highlight"); + AddBoolProperty(info, 0x0Bu, true); + var b = CreateButton(info); + + b.Selected = true; + Assert.Equal("Highlight", b.ActiveState); + + b.Selected = false; + Assert.Equal("Normal", b.ActiveState); + } + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label + /// color/outline reacts to the REQUESTED retail state id even when the + /// standard art-availability gate never lets ActiveState reach it — the + /// Appearance spins' exact shape (their arrow face segments carry no + /// Highlight media at all, so ActiveState is permanently stuck at + /// "Normal", but the label text must still recolor). Live-DAT-measured + /// values: Normal (218,167,85), Highlight (255,221,131), outline + /// off -> on. + /// + [Fact] + public void PerStateLabelStyle_AppliesEvenWhenActiveStateCannotReachIt() + { + var info = ButtonInfo("Normal"); // no Highlight media at all + AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior + var b = CreateButton(info); + b.Label = "Hair Style"; + b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f); + + var colors = new Dictionary + { + [UiButtonStateMachine.Normal] = new(218f / 255f, 167f / 255f, 85f / 255f, 1f), + [UiButtonStateMachine.Highlight] = new(255f / 255f, 221f / 255f, 131f / 255f, 1f), + }; + var outlines = new Dictionary + { + [UiButtonStateMachine.Normal] = false, + [UiButtonStateMachine.Highlight] = true, + }; + b.SetPerStateLabelStyle(colors, outlines); + + Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor); + Assert.False(b.Outline); + + b.Selected = true; + + // The art stays "Normal" (no Highlight media exists to commit to) — + // this is the exact AP-222 no-op the standard gate always produced — + // but the label color/outline must still reach the Highlight values. + Assert.Equal("Normal", b.ActiveState); + Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor); + Assert.True(b.Outline); + } + + /// + /// Regression pin: a button with NO per-state color map (null, the + /// overwhelming majority — every existing external post-construction + /// LabelColor assignment such as ChatWindowController's Send caption or + /// PaperdollController's Slots label) never has its LabelColor touched + /// by a state change. + /// + [Fact] + public void PerStateLabelStyle_Absent_ExternalLabelColorAssignmentSurvivesStateChanges() + { + var info = ButtonInfo("Normal", "Highlight"); + AddBoolProperty(info, 0x0Bu, true); + var b = CreateButton(info); + var externalColor = new System.Numerics.Vector4(1f, 0.92f, 0.72f, 1f); + b.LabelColor = externalColor; + + b.Selected = true; + Assert.Equal("Highlight", b.ActiveState); + Assert.Equal(externalColor, b.LabelColor); + + b.Selected = false; + Assert.Equal(externalColor, b.LabelColor); + } + private static UiButton ButtonWithStates(params string[] states) { var info = ButtonInfo(states); From 691b925952660edd5018647ff0a826d8987920a1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 11:44:46 +0200 Subject: [PATCH 116/138] fix #406: launcher session exit observation carries the real code + captures client stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameWindow.Dispose() (via Program.cs's `using var window = ...`) runs unconditionally even when invoked mid-unwind of an exception that escaped Run()'s Silk.NET frame loop. Resource teardown itself can converge cleanly regardless, so CompleteShutdown had no way to tell "normal Run() return" from "a crash is propagating through me right now" and always wrote the hardcoded exited{code:0,reason:"graceful"} — exactly the symptom #406 observed against a real 0xE0434352 crash. Fixed by latching _runFailure in Run()'s existing catch block (before the pre-existing throw) and consulting it from a new ReportExited method, the one call site for the terminal status write: crashed(1)/graceful(0)/ shutdown-incomplete(1) as appropriate. No wire-contract amendment needed — §LA1 pins the exited event NAME, and reason is already free text that StatusEventParser round-trips unchanged. Sibling gap fixed in the same commit: the launcher discarded the child's stdout/stderr entirely, which is why diagnosing this exact crash required a manual console re-run. Added BoundedProcessOutputCapture, a 2 MiB-capped sink mirroring SessionStatusWriter's open-append-flush-close-per-write posture (a long-lived write handle is not actually concurrently readable on Windows even with FileShare.Read — confirmed by isolated repro), wired into both SystemChildProcess (ProcessStartInfo.RedirectStandardError; Linux + Windows graphical children, i.e. this bug's own scenario) and WindowsSystemChildProcess (a real native pipe via CreateChildOutputPipe, mirroring the existing stdin pipe; Windows console-capable/Headless children). Opt-in via LauncherProcessSpec.StderrLogPath (null = unchanged behavior), threaded through SessionConfigComposer -> client.err.log beside status.jsonl -> LauncherExecutableSet -> LauncherOrchestrator. Tests: GameWindowCrashStatusTests (source-shape, matching the existing GameWindow test pattern — the class cannot be constructed without a live GPU/window), BoundedProcessOutputCaptureTests (10 unit tests), and three new LauncherProcessSupervisorTests spawning real child processes through both capture code paths. Launcher.Core.Tests: 337/0 (was 324/0). Launcher.Tests: 67/0 (unchanged). Full solution build green. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 64 ++++- src/AcDream.App/Rendering/GameWindow.cs | 51 +++- .../Launching/BoundedProcessOutputCapture.cs | 238 +++++++++++++++++ .../Launching/ILauncherChildProcess.cs | 32 +++ .../Launching/LauncherProcessSpec.cs | 7 +- .../Launching/SessionConfigComposer.cs | 29 ++- .../Launching/WindowsSystemChildProcess.cs | 160 +++++++++++- .../Orchestration/LauncherExecutableSet.cs | 16 +- .../Orchestration/LauncherOrchestrator.cs | 7 +- .../Rendering/GameWindowCrashStatusTests.cs | 139 ++++++++++ .../Program.cs | 29 +++ .../BoundedProcessOutputCaptureTests.cs | 240 ++++++++++++++++++ .../LauncherProcessSupervisorTests.cs | 176 +++++++++++++ 13 files changed, 1150 insertions(+), 38 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs create mode 100644 tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 307ebde0..e189a20a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -103,26 +103,62 @@ amendment. Immediate workaround (confirmed live): drag-resize the windowed client — resize events rebuild the swapchain (#387) and the retail UI rescales from its 800x600 authored canvas. -## #406 — Launcher records a crashed client as `exited{code:0,reason:"graceful"}` +## #406 — CLOSED: Launcher records a crashed client as `exited{code:0,reason:"graceful"}` -**Status:** OPEN (Campaign CC gate round 1, 2026-08-16) +**Status:** DONE (this commit, 2026-08-16) **Severity:** MEDIUM (diagnosis-misleading, not data-loss) Found while diagnosing #405: the client process died with exit code `0xE0434352` (.NET unhandled exception, stack on stderr), but the launcher's session status stream recorded `{"e":"exited","code":0, -"reason":"graceful"}` — the exact opposite of what happened. Running the -identical binary + session config from a console shows the true nonzero -exit code, so the corruption is in the launcher's session-orchestrator -exit observation (wrong process handle/exit-code read, or a default that -masks the real code), not in the client. §LA1 explicitly promises -`exited{code,reason}` carries the real termination; a launcher that -reports "graceful" for a crash sends any future gate/automation -diagnosis in the wrong direction (it did exactly that this round until -the console repro). Investigate the launcher-side session orchestrator's -exit capture; a test should pin a nonzero-exit child producing -`exited{code:,reason:"crashed"|"failed"}` per the LA contract's -vocabulary. +"reason":"graceful"}` — the exact opposite of what happened. + +Root cause was NOT in the launcher's process supervision (its own +OS-level exit-code read was always correct) — it was in the CLIENT's own +self-report. `GameWindow.Dispose()` (`src/AcDream.App/Rendering/GameWindow.cs`) +runs unconditionally via `Program.cs`'s `using var window = new +GameWindow(...)` even when invoked mid-unwind of an exception that +escaped `Run()`'s Silk.NET frame loop — the resource-shutdown transaction +itself can converge cleanly (nothing it tears down touches the crash), +so `CompleteShutdown` had no way to tell "normal `Run()` return" from "an +exception is propagating through me right now" and always wrote the +hardcoded `exited{code:0,reason:"graceful"}`. Fixed by latching +`_runFailure` in `Run()`'s existing `catch (Exception failure)` block +(right before the `throw;` that already existed for the +`_constructionCleanup.RetainFrom(failure)` ledger) and consulting it from +a new `ReportExited` method that is now the ONE call site for the +terminal status write: `exited{code:1,reason:"crashed"}` when a crash was +observed, `exited{code:0,reason:"graceful"}` on a real graceful +Dispose(), `exited{code:1,reason:"shutdown-incomplete"}` unchanged for a +non-crash teardown failure. `"crashed"` is a new value for the already- +free-text `reason` field (§LA1's `exited{code,reason}` vocabulary pins +the EVENT name, not an enum of `reason` strings — `StatusEventParser` +already round-trips any string there) so no wire-contract amendment was +needed. Pinned as a source-shape test (`GameWindowCrashStatusTests`) since +`GameWindow` cannot be constructed without a live GPU/window. + +Sibling gap fixed in the same commit: the launcher previously discarded +the child's stdout/stderr entirely, which is why diagnosing this exact +crash required a manual console re-run. Added +`BoundedProcessOutputCapture` (`src/AcDream.Launcher.Core/Launching/`) — +a 2 MiB-capped, additive-only sink mirroring `SessionStatusWriter`'s +open-append-flush-close-per-write posture (a long-lived write handle is +NOT actually concurrently readable on Windows even with +`FileShare.Read` — confirmed by isolated repro) — wired into BOTH +`SystemChildProcess` (`ProcessStartInfo.RedirectStandardError` + +`ErrorDataReceived`; used on Linux for every child and on Windows for +graphical/non-console children, i.e. exactly this bug's own App/GUI +scenario) and `WindowsSystemChildProcess` (a real native pipe via a new +`CreateChildOutputPipe`, mirroring the existing stdin pipe in the +opposite direction, drained on a background pump thread; used on Windows +for console-capable children, i.e. Headless). The capture path is opt-in +via a new `LauncherProcessSpec.StderrLogPath` (null = behave exactly as +before) threaded through `SessionConfigComposer` → `client.err.log` +beside `status.jsonl` in the per-session directory → +`LauncherExecutableSet.CreatePlaySpec`/`CreateProbeSpec` → +`LauncherOrchestrator`. Real end-to-end tests +(`LauncherProcessSupervisorTests`) spawn an actual child via both code +paths and assert the captured file. ## #405 — CLOSED: chargen/summary preview leases missing Transfer killed every retail-UI window load diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4a0bc6aa..50cc1d79 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -137,6 +137,14 @@ public sealed class GameWindow : _constructionCleanup = new(); private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment; private readonly GameWindowLifetime _lifetime = new(); + // fix #406: set by Run()'s own catch the instant an exception escapes + // the Silk.NET frame loop, BEFORE it is rethrown and unwinds through + // Program.cs's `using var window = ...` (which calls Dispose() — + // therefore CompleteShutdown() — while that exception is still in + // flight). CompleteShutdown consults this so a crash is never reported + // as the hardcoded "exited{code:0,reason:graceful}" the resource + // teardown transaction's own convergence would otherwise imply. + private Exception? _runFailure; private readonly DisplayFramePacingController _displayFramePacing; private readonly RuntimeSettingsController _runtimeSettings; @@ -820,6 +828,10 @@ public sealed class GameWindow : catch (Exception failure) { _constructionCleanup.RetainFrom(failure); + // fix #406: latch BEFORE rethrowing — Dispose() (and therefore + // CompleteShutdown) can run mid-unwind of this exact exception, + // via Program.cs's `using var window = ...`. + _runFailure = failure; throw; } } @@ -1698,7 +1710,7 @@ public sealed class GameWindow : // OnClosing() native-window-close-request pass) represents the // process actually being done. if (releaseNativeWindow) - _statusWriter.Exited(_options.SessionId ?? "app", 0, "graceful"); + ReportExited(report); return; } @@ -1715,12 +1727,41 @@ public sealed class GameWindow : Console.Error.WriteLine($"[shutdown] {report.Error}"); if (releaseNativeWindow) + ReportExited(report); + } + + /// + /// Writes the ONE terminal "exited" status event for this session + /// (fix #406). A resource-shutdown transaction can converge cleanly + /// ('s own + /// says nothing about this) even though this call + /// is running mid-unwind of an exception that escaped + /// 's frame loop and is about to terminate the process + /// via the CLR's unhandled-exception path — + /// is the one signal that actually distinguishes those two cases. + /// Before this fix every such crash wrote the exact same + /// "exited{code:0,reason:graceful}" as a real graceful shutdown, + /// sending any launcher-side diagnosis in the wrong direction (#406). + /// + private void ReportExited(GameWindowLifetimeReport report) + { + string sessionId = _options.SessionId ?? "app"; + if (_runFailure is not null) { - _statusWriter.Exited( - _options.SessionId ?? "app", - 1, - "shutdown-incomplete"); + // The real OS-level exit code (e.g. 0xE0434352 on Windows for + // an unhandled .NET exception) is produced by the runtime AFTER + // this method returns and the exception keeps propagating — it + // cannot be predicted from here. "crashed" is the truthful, + // platform-independent classification; the launcher's own + // process supervisor observes the real OS exit code separately. + _statusWriter.Exited(sessionId, 1, "crashed"); + return; } + + if (report.Status == GameWindowLifetimeStatus.Complete) + _statusWriter.Exited(sessionId, 0, "graceful"); + else + _statusWriter.Exited(sessionId, 1, "shutdown-incomplete"); } private GameWindowShutdownRoots CaptureShutdownRoots() => new( diff --git a/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs new file mode 100644 index 00000000..0803a711 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs @@ -0,0 +1,238 @@ +using System.Text; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Captures a supervised child's stderr into a per-session file, bounded +/// so a log-spamming (or endlessly crash-looping) child can never fill the +/// disk (fix #406 sibling gap). Before this class existed the launcher +/// discarded a child's stdout/stderr entirely — including the unhandled- +/// exception stack trace a crash writes there — so diagnosing exactly the +/// #406 crash required re-running the identical binary + session config +/// from a console by hand. This is purely additive diagnostics: it does +/// not touch the pinned status.jsonl event vocabulary (Campaign LA +/// plan §LA1) at all. +/// +/// +/// Every write opens the file fresh (), +/// writes its chunk, flushes, and closes — mirroring +/// 's "no long- +/// lived file handle" posture exactly, and for the SAME reason: a +/// long-lived write handle only opened with +/// is NOT actually concurrently readable in practice — Windows' sharing +/// check is bidirectional, and a plain File.ReadAllText-style +/// reader (which itself only requests , not +/// ) fails with a sharing violation +/// against ANY still-open handle that holds write access, regardless of +/// what share flags that writer declared. Opening fresh per write avoids +/// the problem entirely: there is never a handle open except for the +/// duration of one small, synchronous write. +/// +/// +/// +/// Every write is defensively guarded the same way +/// guards its own +/// I/O: a recoverable failure latches this sink into a permanent no-op +/// rather than throwing back into the caller's read-and-forward loop. The +/// launcher's job is to supervise the child, not to go down because a +/// local diagnostics file could not be written. +/// +/// +/// +/// Callers are responsible for continuing to drain the child's stderr +/// stream/pipe even after this sink stops accepting bytes (cap reached or +/// latched off) — this class only bounds what lands on disk, never how +/// much the caller may read. A caller that stopped draining on a full +/// sink could leave the child blocked writing to a full OS pipe buffer. +/// +/// +public sealed class BoundedProcessOutputCapture : IDisposable +{ + /// 2 MiB is generous for the lifecycle/shutdown diagnostics + /// and a crash stack trace this exists to capture, while still being a + /// firm, small bound against a pathological child that spams stderr + /// for an entire long-running headless-bot session. + public const long DefaultMaxBytes = 2 * 1024 * 1024; + + private static readonly byte[] Newline = "\n"u8.ToArray(); + + private readonly string _path; + private readonly long _maxBytes; + private readonly object _gate = new(); + private bool _directoryEnsured; + private long _written; + private bool _capped; + private bool _latchedOff; + private bool _disposed; + + public BoundedProcessOutputCapture(string path, long maxBytes = DefaultMaxBytes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (maxBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maxBytes), + "The bounded capture size must be positive."); + } + + _path = Path.GetFullPath(path); + _maxBytes = maxBytes; + } + + /// True once no further byte will ever be written — either + /// the size cap was reached (a truncation marker was appended) or a + /// local I/O failure latched this sink off. Exposed for tests; a + /// caller never needs to check this before calling + /// — it is always safe to call. + public bool IsDone + { + get + { + lock (_gate) + { + return _capped || _latchedOff || _disposed; + } + } + } + + /// Appends one line of already-decoded text (e.g. one + /// Process.ErrorDataReceived line) followed by a newline. Never + /// throws. A line (the sentinel .NET's + /// ErrorDataReceived raises once when the stream closes) is a + /// silent no-op. + public void AppendLine(string? line) + { + if (line is null) + { + return; + } + + lock (_gate) + { + AppendLocked(Encoding.UTF8.GetBytes(line)); + AppendLocked(Newline); + } + } + + /// Appends a raw decoded chunk (no implied line boundary). + /// Never throws. + public void Append(ReadOnlySpan data) + { + if (data.IsEmpty) + { + return; + } + + lock (_gate) + { + AppendLocked(data); + } + } + + private void AppendLocked(ReadOnlySpan data) + { + if (data.IsEmpty || _disposed || _latchedOff || _capped) + { + return; + } + + try + { + long remaining = _maxBytes - _written; + if (remaining <= 0) + { + CapLocked(); + return; + } + + int toWrite = data.Length > remaining + ? checked((int)remaining) + : data.Length; + WriteChunkLocked(data[..toWrite]); + _written += toWrite; + + if (toWrite < data.Length) + { + CapLocked(); + } + } + catch (Exception error) when (IsRecoverableIoFailure(error)) + { + _latchedOff = true; + } + } + + /// Opens the file fresh, writes one chunk, flushes, and + /// closes — see the class doc for why this never keeps a long-lived + /// handle. Exceptions propagate to the caller's own guard. + private void WriteChunkLocked(ReadOnlySpan chunk) + { + EnsureDirectoryLocked(); + using FileStream stream = new( + _path, + FileMode.Append, + FileAccess.Write, + FileShare.Read); + stream.Write(chunk); + stream.Flush(); + } + + private void EnsureDirectoryLocked() + { + if (_directoryEnsured) + { + return; + } + + string? directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + _directoryEnsured = true; + } + + /// Writes the one-time truncation marker — every later + /// / becomes a cheap + /// no-op via . + private void CapLocked() + { + if (_capped) + { + return; + } + + _capped = true; + try + { + byte[] marker = Encoding.UTF8.GetBytes( + $"\n[acdream-launcher] client.err.log truncated at {_maxBytes} bytes\n"); + WriteChunkLocked(marker); + } + catch (Exception error) when (IsRecoverableIoFailure(error)) + { + // The marker itself is best-effort — the cap already took + // effect via _capped regardless of whether it could be written. + } + } + + private static bool IsRecoverableIoFailure(Exception error) => + error is IOException + or UnauthorizedAccessException + or NotSupportedException + or System.Security.SecurityException + or DirectoryNotFoundException; + + /// No open handle to release — see the class doc. Marks this + /// sink permanently done so any late-arriving chunk from a caller's + /// still-draining pump is a silent no-op instead of reopening the + /// file after the caller considers capture finished. + public void Dispose() + { + lock (_gate) + { + _disposed = true; + } + } +} diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs index b8816ab7..b3f9a3ce 100644 --- a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -88,7 +88,9 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess private readonly Process _process; private readonly bool _supportsConsoleGracefulStop; + private readonly BoundedProcessOutputCapture? _stderrCapture; private bool _raisingEnabled; + private bool _errorReadingEnabled; internal SystemChildProcess(LauncherProcessSpec spec) { @@ -102,6 +104,17 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess UseShellExecute = false, }; + // fix #406 sibling gap: capture stderr (crash stack traces land + // there) into a bounded per-session file instead of discarding it. + // Purely additive — RedirectStandardOutput/CreateNoWindow are left + // untouched, and a spec with no StderrLogPath behaves exactly as + // before. + if (!string.IsNullOrWhiteSpace(spec.StderrLogPath)) + { + startInfo.RedirectStandardError = true; + _stderrCapture = new BoundedProcessOutputCapture(spec.StderrLogPath); + } + foreach (string argument in spec.Arguments) { startInfo.ArgumentList.Add(argument); @@ -128,7 +141,17 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess _process.EnableRaisingEvents = true; _process.Exited += OnExited; _raisingEnabled = true; + if (_stderrCapture is not null) + { + _process.ErrorDataReceived += OnErrorDataReceived; + _errorReadingEnabled = true; + } + _process.Start(); + if (_errorReadingEnabled) + { + _process.BeginErrorReadLine(); + } } public bool TryRequestGracefulStop() @@ -169,9 +192,18 @@ internal sealed partial class SystemChildProcess : ILauncherChildProcess _process.Exited -= OnExited; } + if (_errorReadingEnabled) + { + _process.ErrorDataReceived -= OnErrorDataReceived; + } + _process.Dispose(); + _stderrCapture?.Dispose(); } private void OnExited(object? sender, EventArgs e) => Exited?.Invoke(this, EventArgs.Empty); + + private void OnErrorDataReceived(object? sender, DataReceivedEventArgs e) => + _stderrCapture?.AppendLine(e.Data); } diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs index 599a932d..9cdac21b 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs @@ -11,9 +11,14 @@ namespace AcDream.Launcher.Core.Launching; /// so Windows starts them /// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and /// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE. +/// is an optional, purely-additive +/// diagnostics sink (fix #406 sibling gap): when set, the launcher +/// captures the child's stderr into a bounded file at that path instead +/// of discarding it; when null, behavior is exactly as before. /// public sealed record LauncherProcessSpec( string ExecutablePath, IReadOnlyList Arguments, string? WorkingDirectory = null, - bool SupportsConsoleGracefulStop = true); + bool SupportsConsoleGracefulStop = true, + string? StderrLogPath = null); diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index 1fceee5c..7d9eb594 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -5,12 +5,17 @@ using AcDream.Platform; namespace AcDream.Launcher.Core.Launching; -/// The composed session-config document plus the two per-launch -/// paths derived from the session id, per Campaign LA spec §6. +/// The composed session-config document plus the per-launch +/// paths derived from the session id, per Campaign LA spec §6. +/// is launcher-internal (fix #406 sibling +/// gap) — it never appears in the written session.json, only in +/// the the launcher spawns the +/// child with. public sealed record ComposedSessionConfig( string SessionId, string ConfigFilePath, string StatusFilePath, + string StderrLogPath, SessionConfigDocument Document); /// @@ -110,7 +115,8 @@ public static class SessionConfigComposer ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - (string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId); + (string configFilePath, string statusFilePath, string stderrLogPath) = + BuildSessionPaths(paths, sessionId); SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect ? null @@ -159,6 +165,7 @@ public static class SessionConfigComposer sessionId, configFilePath, statusFilePath, + stderrLogPath, document); } @@ -184,7 +191,8 @@ public static class SessionConfigComposer ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - (string configFilePath, string statusFilePath) = BuildSessionPaths(paths, sessionId); + (string configFilePath, string statusFilePath, string stderrLogPath) = + BuildSessionPaths(paths, sessionId); var descriptor = new SessionDescriptor { @@ -222,6 +230,7 @@ public static class SessionConfigComposer sessionId, configFilePath, statusFilePath, + stderrLogPath, document); } @@ -282,9 +291,10 @@ public static class SessionConfigComposer public static string Serialize(SessionConfigDocument document) => JsonSerializer.Serialize(document, SerializerOptions); - private static (string ConfigFilePath, string StatusFilePath) BuildSessionPaths( - ApplicationPathSet paths, - string sessionId) + private static (string ConfigFilePath, string StatusFilePath, string StderrLogPath) + BuildSessionPaths( + ApplicationPathSet paths, + string sessionId) { string sessionDirectory = Path.Combine( paths.CacheDirectory, @@ -294,7 +304,10 @@ public static class SessionConfigComposer return ( Path.Combine(sessionDirectory, "session.json"), - Path.Combine(sessionDirectory, "status.jsonl")); + Path.Combine(sessionDirectory, "status.jsonl"), + // fix #406 sibling gap: lives beside status.jsonl in the same + // per-session directory. + Path.Combine(sessionDirectory, "client.err.log")); } private static SessionCharacterSelector BuildSelector(CharacterProfile character) diff --git a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs index 00138c30..335f58cb 100644 --- a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs +++ b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs @@ -20,6 +20,10 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess private TextWriter? _standardInput; private int _processGroupId; private bool _raisingEnabled; + // fix #406 sibling gap: null unless _spec.StderrLogPath was set. + private BoundedProcessOutputCapture? _stderrCapture; + private FileStream? _stderrReadStream; + private Thread? _stderrPumpThread; internal WindowsSystemChildProcess( LauncherProcessSpec spec, @@ -54,6 +58,30 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess _raisingEnabled = true; _standardInput = started.TakeStandardInput(); _processGroupId = started.ProcessId; + if (!string.IsNullOrWhiteSpace(_spec.StderrLogPath)) + { + // fix #406 sibling gap: drain the real stderr pipe + // WindowsProcessNative.StartCore created for this spec into + // a bounded file, BEFORE resuming the suspended child below + // — the pump is already running by the time the child can + // write anything. + SafeFileHandle stderrRead = started.TakeStandardErrorRead() + ?? throw new InvalidOperationException( + "The launcher child stderr pipe was not created."); + _stderrCapture = new BoundedProcessOutputCapture(_spec.StderrLogPath); + _stderrReadStream = new FileStream( + stderrRead, + FileAccess.Read, + 4096, + isAsync: false); + _stderrPumpThread = new Thread(PumpStderr) + { + IsBackground = true, + Name = "acdream-launcher-stderr-pump", + }; + _stderrPumpThread.Start(); + } + started.Resume(); } catch @@ -61,6 +89,10 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess started.Terminate(); _standardInput?.Dispose(); _standardInput = null; + _stderrReadStream?.Dispose(); + _stderrReadStream = null; + _stderrCapture?.Dispose(); + _stderrCapture = null; if (_process is not null) { if (_raisingEnabled) @@ -80,6 +112,43 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess } } + /// + /// Runs on a dedicated background thread for the lifetime of the + /// capture (fix #406 sibling gap): continuously drains the child's + /// stderr pipe into so the child's writes + /// never block on a full OS pipe buffer, even after the capture sink + /// itself has stopped accepting bytes (size cap reached, or a local + /// I/O failure latched it off — + /// never throws). Returns cleanly once the pipe's write end closes + /// (the child exited) or closes the read end. + /// + private void PumpStderr() + { + FileStream? stream = _stderrReadStream; + BoundedProcessOutputCapture? capture = _stderrCapture; + if (stream is null || capture is null) + { + return; + } + + byte[] buffer = new byte[4096]; + try + { + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + capture.Append(buffer.AsSpan(0, read)); + } + } + catch (Exception error) + when (error is IOException or ObjectDisposedException) + { + // The pipe's write end closed (the child exited) or Dispose() + // released the read end concurrently — either way, this pump + // is simply done; never propagate onto this background thread. + } + } + public bool TryRequestGracefulStop() { try @@ -111,6 +180,21 @@ internal sealed class WindowsSystemChildProcess : ILauncherChildProcess { _standardInput?.Dispose(); _standardInput = null; + if (_stderrReadStream is not null) + { + // Closing the read end unblocks PumpStderr's pending Read() + // (ObjectDisposedException, caught there). The bounded Join + // lets that last in-flight chunk land in the capture file + // before it is disposed below, without letting a wedged pump + // thread ever hang this Dispose() call. + _stderrReadStream.Dispose(); + _stderrReadStream = null; + _stderrPumpThread?.Join(TimeSpan.FromSeconds(2)); + _stderrPumpThread = null; + } + + _stderrCapture?.Dispose(); + _stderrCapture = null; if (_process is not null) { if (_raisingEnabled) @@ -225,18 +309,21 @@ internal sealed class WindowsProcessStartResult : IDisposable private readonly SafeKernelHandle _processHandle; private readonly SafeKernelHandle _threadHandle; private SafeFileHandle? _standardInput; + private SafeFileHandle? _stderrRead; private bool _resumed; internal WindowsProcessStartResult( int processId, SafeKernelHandle processHandle, SafeKernelHandle threadHandle, - SafeFileHandle standardInput) + SafeFileHandle standardInput, + SafeFileHandle? stderrRead = null) { ProcessId = processId; _processHandle = processHandle; _threadHandle = threadHandle; _standardInput = standardInput; + _stderrRead = stderrRead; } internal int ProcessId { get; } @@ -263,6 +350,22 @@ internal sealed class WindowsProcessStartResult : IDisposable } } + /// + /// Transfers ownership of the parent-side stderr pipe read handle (fix + /// #406 sibling gap) — non-null only when + /// was set, in which case + /// created a real pipe for the child's stderr instead of the usual + /// duplicate-or-NUL handle. Returns null if capture was not requested, + /// or if this handle was already claimed. The caller owns disposal + /// after this call. + /// + internal SafeFileHandle? TakeStandardErrorRead() + { + SafeFileHandle? handle = _stderrRead; + _stderrRead = null; + return handle; + } + internal void Resume() { if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue) @@ -290,6 +393,7 @@ internal sealed class WindowsProcessStartResult : IDisposable } _standardInput?.Dispose(); + _stderrRead?.Dispose(); _threadHandle.Dispose(); _processHandle.Dispose(); } @@ -361,13 +465,20 @@ internal static class WindowsProcessNative private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec) { SafeFileHandle? parentInput = null; + // fix #406 sibling gap: when the spec requests stderr capture, the + // child's stderr handle is a real pipe (this parent-side read end) + // instead of the usual duplicate-or-NUL handle below. + SafeFileHandle? parentStderrRead = null; + SafeHandle? childError = null; try { using SafeFileHandle childInput = CreateChildInputPipe( out SafeFileHandle createdParentInput); parentInput = createdParentInput; using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle); - using SafeKernelHandle childError = DuplicateOrOpenNull(StdErrorHandle); + childError = string.IsNullOrWhiteSpace(spec.StderrLogPath) + ? DuplicateOrOpenNull(StdErrorHandle) + : CreateChildOutputPipe(out parentStderrRead); using var attributes = new ProcessThreadAttributeList( childInput.DangerousGetHandle(), childOutput.DangerousGetHandle(), @@ -420,8 +531,10 @@ internal static class WindowsProcessNative checked((int)information.ProcessId), processHandle, threadHandle, - parentInput); + parentInput, + parentStderrRead); parentInput = null; + parentStderrRead = null; return result; } catch @@ -435,6 +548,8 @@ internal static class WindowsProcessNative finally { parentInput?.Dispose(); + parentStderrRead?.Dispose(); + childError?.Dispose(); } } @@ -525,6 +640,45 @@ internal static class WindowsProcessNative return child; } + /// + /// Mirror of with the roles + /// reversed (fix #406 sibling gap): the CHILD gets the pipe's WRITE + /// end (its stderr handle, inheritable across + /// ), the PARENT keeps the READ end + /// (inherit flag cleared, exactly like 's + /// counterpart on the stdin pipe) so the launcher can drain the + /// child's stderr into a bounded file. + /// + private static SafeFileHandle CreateChildOutputPipe(out SafeFileHandle parentRead) + { + var security = new SecurityAttributes + { + Length = Marshal.SizeOf(), + InheritHandle = true, + }; + if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child stderr pipe could not be created."); + } + + var child = new SafeFileHandle(write, ownsHandle: true); + parentRead = new SafeFileHandle(read, ownsHandle: true); + if (!SetHandleInformation( + parentRead, + HandleFlagInherit, + 0)) + { + int error = Marshal.GetLastWin32Error(); + child.Dispose(); + parentRead.Dispose(); + throw new Win32Exception(error, + "The launcher child stderr pipe could not be isolated."); + } + + return child; + } + private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle) { IntPtr source = GetStdHandle(standardHandle); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs index 5a98b795..4add2816 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -91,7 +91,8 @@ public sealed class LauncherExecutableSet public LauncherProcessSpec CreatePlaySpec( LaunchMode mode, - string configFilePath) + string configFilePath, + string? stderrLogPath = null) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); ExecutablePaths paths = RequireAvailable(mode); @@ -100,22 +101,27 @@ public sealed class LauncherExecutableSet ? new LauncherProcessSpec( paths.HeadlessHostPath, ["--config", configFilePath], - paths.WorkingDirectory) + paths.WorkingDirectory, + StderrLogPath: stderrLogPath) : new LauncherProcessSpec( paths.GraphicalHostPath, ["--session-config", configFilePath], paths.WorkingDirectory, - SupportsConsoleGracefulStop: false); + SupportsConsoleGracefulStop: false, + StderrLogPath: stderrLogPath); } - public LauncherProcessSpec CreateProbeSpec(string configFilePath) + public LauncherProcessSpec CreateProbeSpec( + string configFilePath, + string? stderrLogPath = null) { ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); ExecutablePaths paths = RequireAvailable(LaunchMode.Headless); return new LauncherProcessSpec( paths.HeadlessHostPath, ["--config", configFilePath], - paths.WorkingDirectory); + paths.WorkingDirectory, + StderrLogPath: stderrLogPath); } public static LauncherExecutableSet FromDirectory(string directory) diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index a734fcaf..9bb48560 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -683,10 +683,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator request.Cancellation.Token.ThrowIfCancellationRequested(); LauncherProcessSpec processSpec = request.IsProbe - ? _executables.CreateProbeSpec(composed.ConfigFilePath) + ? _executables.CreateProbeSpec( + composed.ConfigFilePath, + composed.StderrLogPath) : _executables.CreatePlaySpec( request.Activity.LaunchMode!.Value, - composed.ConfigFilePath); + composed.ConfigFilePath, + composed.StderrLogPath); supervisor.Start(processSpec, password); hostStarted = true; diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs new file mode 100644 index 00000000..779aa6d8 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GameWindowCrashStatusTests.cs @@ -0,0 +1,139 @@ +namespace AcDream.App.Tests.Rendering; + +/// +/// Fix #406: before this change, GameWindow.CompleteShutdown wrote +/// a hardcoded exited{code:0,reason:"graceful"} status event +/// whenever the resource-shutdown transaction converged — even when +/// Dispose() (and therefore CompleteShutdown) ran mid-unwind +/// of an exception that escaped Run()'s Silk.NET frame loop and was +/// about to crash the process via the CLR's unhandled-exception path. +/// GameWindow cannot be constructed without a live GPU/window (see +/// the established pattern in GameWindowSlice8BoundaryTests), so +/// this pins the fix as a source-shape test exactly like that file does +/// for the surrounding shutdown machinery. +/// +public sealed class GameWindowCrashStatusTests +{ + [Fact] + public void Run_LatchesRunFailureBeforeRethrowingFromTheFrameLoopCatch() + { + string body = MethodBody( + "public void Run()", + "void IGameWindowPlatformPublication.PublishGraphics("); + string tryBlock = Slice(body, "try\n {\n _window.Run();", "}\n }"); + + AssertAppearsInOrder( + tryBlock, + "_window.Run();", + "catch (Exception failure)", + "_constructionCleanup.RetainFrom(failure);", + // The latch MUST happen before the rethrow: Dispose() (and + // therefore CompleteShutdown/ReportExited) can run mid-unwind + // of this exact exception, via Program.cs's + // `using var window = ...`. + "_runFailure = failure;", + "throw;"); + } + + [Fact] + public void ReportExited_ChecksRunFailureBeforeEitherGracefulOrShutdownIncompletePaths() + { + string source = GameWindowSource(); + string reportExited = Slice( + source, + "private void ReportExited(GameWindowLifetimeReport report)", + "\n }\n"); + + Assert.Contains( + "string sessionId = _options.SessionId ?? \"app\";", + reportExited, + StringComparison.Ordinal); + AssertAppearsInOrder( + reportExited, + "if (_runFailure is not null)", + "_statusWriter.Exited(sessionId, 1, \"crashed\");", + "return;", + "if (report.Status == GameWindowLifetimeStatus.Complete)", + "_statusWriter.Exited(sessionId, 0, \"graceful\");", + "_statusWriter.Exited(sessionId, 1, \"shutdown-incomplete\");"); + + // Every terminal-status write in CompleteShutdown funnels through + // this ONE method — a second, uncoordinated call site would be + // exactly how the pre-fix bug reappears. + Assert.Equal( + 2, + CountOccurrences(source, "ReportExited(report)")); + Assert.DoesNotContain( + "_statusWriter.Exited(_options.SessionId ?? \"app\", 0, \"graceful\")", + source, + StringComparison.Ordinal); + } + + [Fact] + public void RunFailureFieldExistsAndDefaultsToNull() + { + string source = GameWindowSource(); + + Assert.Contains( + "private Exception? _runFailure;", + source, + StringComparison.Ordinal); + } + + private static string MethodBody(string start, string end) => + Slice(GameWindowSource(), start, end); + + private static string Slice(string source, string start, string end) + { + int first = source.IndexOf(start, StringComparison.Ordinal); + int last = source.IndexOf(end, first + 1, StringComparison.Ordinal); + Assert.True(first >= 0, $"Missing source boundary: {start}"); + Assert.True(last > first, $"Missing source boundary: {end}"); + return source[first..last]; + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int cursor = 0; + while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0) + { + count++; + cursor += value.Length; + } + + return count; + } + + private static void AssertAppearsInOrder(string source, params string[] fragments) + { + int cursor = -1; + foreach (string fragment in fragments) + { + int next = source.IndexOf(fragment, cursor + 1, StringComparison.Ordinal); + Assert.True(next >= 0, $"Missing expected source fragment: {fragment}"); + Assert.True(next > cursor, $"Out-of-order source fragment: {fragment}"); + cursor = next; + } + } + + private static string GameWindowSource() => File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs index dc047cdf..092616a0 100644 --- a/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs +++ b/tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/Program.cs @@ -1,5 +1,34 @@ using System.Text.Json; +// fix #406 sibling gap: a "write-stderr" mode alongside the existing +// "wait-for-break" mode so the launcher's stderr-capture tests can drive a +// real child process (both WindowsSystemChildProcess's native pipe path +// and SystemChildProcess's ProcessStartInfo.RedirectStandardError path) +// without a second fixture project. Usage: +// write-stderr +// Writes followed by its 0-based index, one per line, to +// stderr times (flushing every line so a launcher-side pump +// observes them incrementally rather than all at once on process exit), +// then returns . +if (args.Length >= 1 && args[0] == "write-stderr") +{ + if (args.Length < 4 + || !int.TryParse(args[1], out int exitCode) + || !int.TryParse(args[2], out int lineCount)) + { + return 64; + } + + string lineText = args[3]; + for (int index = 0; index < lineCount; index++) + { + Console.Error.WriteLine($"{lineText}{index}"); + Console.Error.Flush(); + } + + return exitCode; +} + if (args.Length < 4 || args[0] != "wait-for-break" || string.IsNullOrWhiteSpace(args[1]) diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs new file mode 100644 index 00000000..5ad94363 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs @@ -0,0 +1,240 @@ +using System.Text; +using AcDream.Launcher.Core.Launching; + +namespace AcDream.Launcher.Core.Tests.Launching; + +/// Fix #406 sibling gap: the launcher previously discarded a +/// supervised child's stderr entirely, so diagnosing a crash (including +/// exactly the #406 crash) required re-running the identical binary by +/// hand. These tests cover in +/// isolation — the real-child-process end-to-end capture tests live in +/// LauncherProcessSupervisorTests alongside the existing real-process +/// coverage. +public sealed class BoundedProcessOutputCaptureTests +{ + [Fact] + public void AppendLineWritesEachLineWithATrailingNewline() + { + string path = TempPath(); + try + { + using var capture = new BoundedProcessOutputCapture(path); + + capture.AppendLine("first"); + capture.AppendLine("second"); + capture.Dispose(); + + Assert.Equal("first\nsecond\n", File.ReadAllText(path)); + } + finally + { + TryDelete(path); + } + } + + [Fact] + public void ANullLineFromTheEndOfStreamSentinelIsANoOp() + { + string path = TempPath(); + try + { + using var capture = new BoundedProcessOutputCapture(path); + + capture.AppendLine("kept"); + capture.AppendLine(null); + capture.Dispose(); + + Assert.Equal("kept\n", File.ReadAllText(path)); + } + finally + { + TryDelete(path); + } + } + + [Fact] + public void WritesBeyondTheCapAreDroppedAndAOneTimeTruncationMarkerIsAppended() + { + string path = TempPath(); + try + { + using var capture = new BoundedProcessOutputCapture(path, maxBytes: 16); + + capture.AppendLine("0123456789"); // 11 bytes incl. newline + capture.AppendLine("this line is dropped entirely"); + capture.AppendLine("so is this one"); + + Assert.True(capture.IsDone); + string written = File.ReadAllText(path); + Assert.StartsWith("0123456789\n", written, StringComparison.Ordinal); + Assert.Contains("truncated at 16 bytes", written, StringComparison.Ordinal); + // The cap is a hard ceiling: nothing past it EVER lands on disk, + // even the marker's own text does not push the file arbitrarily + // far past the configured bound. + Assert.True( + written.Length < 200, + $"expected a small bounded file, got {written.Length} bytes"); + } + finally + { + TryDelete(path); + } + } + + [Fact] + public void ALogSpammingChildCannotGrowTheFileUnboundedly() + { + string path = TempPath(); + try + { + using var capture = new BoundedProcessOutputCapture( + path, + maxBytes: BoundedProcessOutputCapture.DefaultMaxBytes); + + // Far more than the 2 MiB default cap. + string spamLine = new('x', 4096); + for (int i = 0; i < 4096; i++) + { + capture.AppendLine(spamLine); + if (capture.IsDone) + { + break; + } + } + + Assert.True(capture.IsDone); + long fileLength = new FileInfo(path).Length; + Assert.True( + fileLength < BoundedProcessOutputCapture.DefaultMaxBytes + 256, + $"expected the file to stay near the {BoundedProcessOutputCapture.DefaultMaxBytes}-byte " + + $"cap, got {fileLength} bytes"); + } + finally + { + TryDelete(path); + } + } + + [Fact] + public void AppendCreatesTheSessionDirectoryOnFirstWrite() + { + string directory = Path.Combine( + Path.GetTempPath(), + "acdream-406-capture-" + Guid.NewGuid().ToString("N")); + string path = Path.Combine(directory, "client.err.log"); + Assert.False(Directory.Exists(directory)); + + try + { + using var capture = new BoundedProcessOutputCapture(path); + capture.AppendLine("hello"); + capture.Dispose(); + + Assert.True(File.Exists(path)); + } + finally + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (IOException) + { + } + } + } + + [Fact] + public void RawByteAppendsAreConcatenatedWithoutAnImpliedLineBoundary() + { + string path = TempPath(); + try + { + using var capture = new BoundedProcessOutputCapture(path); + + capture.Append(Encoding.UTF8.GetBytes("abc")); + capture.Append(Encoding.UTF8.GetBytes("def")); + capture.Dispose(); + + Assert.Equal("abcdef", File.ReadAllText(path)); + } + finally + { + TryDelete(path); + } + } + + [Fact] + public void EmptyAppendsAreNoOps() + { + string path = TempPath(); + try + { + using var capture = new BoundedProcessOutputCapture(path); + + capture.Append(ReadOnlySpan.Empty); + capture.AppendLine(string.Empty); + capture.Dispose(); + + // An empty string line still gets its trailing newline — + // only a genuinely zero-length byte span (or a null line) is + // a true no-op. + Assert.Equal("\n", File.ReadAllText(path)); + } + finally + { + TryDelete(path); + } + } + + [Fact] + public void AppendAfterDisposeIsASilentNoOp() + { + string path = TempPath(); + try + { + var capture = new BoundedProcessOutputCapture(path); + capture.AppendLine("before"); + capture.Dispose(); + + capture.AppendLine("after — must not throw or reopen the file"); + + Assert.Equal("before\n", File.ReadAllText(path)); + } + finally + { + TryDelete(path); + } + } + + [Fact] + public void ConstructorRejectsANonPositiveMaxBytes() + { + Assert.Throws( + () => new BoundedProcessOutputCapture(TempPath(), maxBytes: 0)); + Assert.Throws( + () => new BoundedProcessOutputCapture(TempPath(), maxBytes: -1)); + } + + [Fact] + public void ConstructorRejectsANullOrBlankPath() + { + Assert.Throws(() => new BoundedProcessOutputCapture("")); + Assert.Throws(() => new BoundedProcessOutputCapture(" ")); + } + + private static string TempPath() => Path.Combine( + Path.GetTempPath(), + "acdream-406-capture-" + Guid.NewGuid().ToString("N") + ".log"); + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index ca55ff60..837ed01b 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -543,6 +543,182 @@ public sealed class LauncherProcessSupervisorTests Assert.Equal(0, supervisor.ExitCode); } + [Fact] + public async Task RealChildStderrIsCapturedForTheProcessStartInfoPath() + { + // Fix #406 sibling gap: SystemChildProcess is used on Linux for + // EVERY child, and on Windows for graphical/non-console children — + // exactly #406's own App/GUI crash scenario + // (SupportsConsoleGracefulStop: false is the graphical shape; + // see LauncherExecutableSet.CreatePlaySpec). This proves the real + // child's stderr actually lands in the configured bounded file + // instead of being discarded, and that the real nonzero exit code + // is still observed independently of the capture. + string root = Path.Combine( + Path.GetTempPath(), + "acdream-406-stderr-psi", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string stderrPath = Path.Combine(root, "client.err.log"); + + try + { + using var supervisor = new LauncherProcessSupervisor(); + var exited = new ManualResetEventSlim(false); + supervisor.StateChanged += (_, s) => + { + if (s == LauncherSessionState.Exited) + exited.Set(); + }; + + supervisor.Start( + new LauncherProcessSpec( + FindDotnetExecutable(), + [GetConsoleFixturePath(), "write-stderr", "7", "3", "line-"], + SupportsConsoleGracefulStop: false, + StderrLogPath: stderrPath), + password: null); + + Assert.True( + exited.Wait(TimeSpan.FromSeconds(30)), + "the write-stderr fixture did not exit within 30s"); + Assert.Equal(7, supervisor.ExitCode); + + string captured = await ReadFileEventuallyContainingAsync( + stderrPath, "line-2", TimeSpan.FromSeconds(5)); + Assert.Contains("line-0", captured, StringComparison.Ordinal); + Assert.Contains("line-1", captured, StringComparison.Ordinal); + Assert.Contains("line-2", captured, StringComparison.Ordinal); + } + finally + { + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + + [Fact] + public async Task RealChildStderrIsCapturedForTheWindowsNativeConsolePath() + { + // Fix #406 sibling gap: on Windows, console-capable children + // (Headless) spawn through WindowsSystemChildProcess's native + // CreateProcessW path, a completely separate code path from + // SystemChildProcess above — WindowsProcessNative.StartCore + // creates a real pipe for stderr instead of the usual + // duplicate-or-NUL handle, and WindowsSystemChildProcess pumps it + // on a background thread. This proves that path end to end too. + if (!OperatingSystem.IsWindows()) + { + return; + } + + string root = Path.Combine( + Path.GetTempPath(), + "acdream-406-stderr-native", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string stderrPath = Path.Combine(root, "client.err.log"); + + try + { + using var supervisor = new LauncherProcessSupervisor(); + var exited = new ManualResetEventSlim(false); + supervisor.StateChanged += (_, s) => + { + if (s == LauncherSessionState.Exited) + exited.Set(); + }; + + supervisor.Start( + new LauncherProcessSpec( + FindDotnetExecutable(), + [GetConsoleFixturePath(), "write-stderr", "9", "3", "native-line-"], + StderrLogPath: stderrPath), + password: null); + + Assert.True( + exited.Wait(TimeSpan.FromSeconds(30)), + "the write-stderr fixture did not exit within 30s"); + Assert.Equal(9, supervisor.ExitCode); + + string captured = await ReadFileEventuallyContainingAsync( + stderrPath, "native-line-2", TimeSpan.FromSeconds(5)); + Assert.Contains("native-line-0", captured, StringComparison.Ordinal); + Assert.Contains("native-line-1", captured, StringComparison.Ordinal); + Assert.Contains("native-line-2", captured, StringComparison.Ordinal); + } + finally + { + try + { + Directory.Delete(root, recursive: true); + } + catch (IOException) + { + } + } + } + + [Fact] + public void ANullStderrLogPathBehavesExactlyAsBeforeForBothChildProcessKinds() + { + // The additive-diagnostics contract: a spec with no StderrLogPath + // must not change behavior at all (fix #406 sibling gap review + // guard against a regression that always redirects stderr). + string dotnet = FindDotnetExecutable(); + using var supervisor = new LauncherProcessSupervisor(); + var exited = new ManualResetEventSlim(false); + supervisor.StateChanged += (_, s) => + { + if (s == LauncherSessionState.Exited) + exited.Set(); + }; + + supervisor.Start(new LauncherProcessSpec(dotnet, ["--version"]), null); + + Assert.True(exited.Wait(TimeSpan.FromSeconds(30))); + Assert.Equal(0, supervisor.ExitCode); + } + + private static async Task ReadFileEventuallyContainingAsync( + string path, + string expectedFragment, + TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + string last = string.Empty; + while (DateTime.UtcNow < deadline) + { + if (File.Exists(path)) + { + try + { + last = await File.ReadAllTextAsync(path); + if (last.Contains(expectedFragment, StringComparison.Ordinal)) + { + return last; + } + } + catch (IOException) + { + // The pump/writer may hold the file open for a + // moment — retry within the deadline. + } + } + + await Task.Delay(20); + } + + throw new TimeoutException( + $"'{path}' never contained '{expectedFragment}' within {timeout}. " + + $"Last observed content: {last}"); + } + private static LauncherProcessSpec Spec() => new("fake-host", ["--session-config", "session.json"]); From 0591b9a026a746be6dda93d1e20f8e0ff7493af8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 12:18:53 +0200 Subject: [PATCH 117/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20C=20=E2=80=94=20rich=20text=20+=20labels=20?= =?UTF-8?q?+=20backdrops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 1/3: chargen-scoped, low blast-radius fixes. - New DatRichText helper: escape-normalize + word-wrap + per-segment palette color, porting UIElement_Text::SetStringInfoWithFont / AppendStringInfoWithFont's composition model. Routes the Heritage (GF-2), Town (GF-11a), and Profession (GF-3) description boxes through it instead of a raw unwrapped single-Line LinesProvider. Heritage headers use font-color palette index 1 (green), bodies index 0 (white), matching AppendStringInfoWithFont's own font-index argument. Town's diagnosed GF-11a root cause: a single un-wrapped line meant the town-specific suffix rendered past the clipped viewport, so switching towns looked like "text never changes" even though the underlying composed string genuinely differed. - GF-3: bind the Profession page's description textbox (0x100003e0, gmCGProfessionPage::InitializePage @0x00483068) and compose its per-template text (UpdateProfession @0x004821b0's CustomText/ BowText/SwashText/LifeText/WarText/WayText/SoldierText, plain SetStringInfo — no palette). - GF-4: UiButton gains a coexisting ValueLabel/ValueBox/ValueFont/ ValueColor slot alongside Label. Retail's chargen display buttons (avail/health/stamina/mana credits, 0x100003e2-e5/0x100003f9) author their caption directly on P0x17 AND carry a separate, media-less Type-12 value child that UiButton.ConsumesDatChildren used to drop entirely — pages substituted the button's own Label, destroying the caption. DatWidgetFactory.BuildButton now surfaces that child (gated on ReferenceEquals(labelInfo, info) — own-caption buttons only) instead. The six Profession slider name labels (0x100002ed, CharGenState::GetAttributeName @0x005C3A20's six hardcoded literals) resolve as UiButton in this port (live-DAT- measured Type 1 — retail's UIElement_Button is DynamicCast(0xc)- compatible with UIElement_Text) and are written once at construction, matching retail's own single InitializePage write. - GF-6/AP-218: gmCGAppearancePage::Update writes a heritage-flavored STATIC caption to the Hair/Eyes/Skin spins (plain / GearText_* / OlthoiText_* variants) — never an index. Removed the prior 1-based- ordinal/gear-name substitution entirely; the other six spins keep their DAT-authored caption untouched, matching retail exactly. - Root 1d: wire the Heritage (0x100003be, 13 states) and Profession (0x100003d8, 7 states) backdrop SetState cascades (gmCGHeritagePage::Update / gmCGProfessionPage::UpdateProfession). - AP-216/AP-217 (partial, register updated honestly): swatches beyond the current part's real color count now hide (DoColorSpots' blank- blit half); the GradCircle now blanks for Eyes (DoGradDisk's blank- plug half). The "paint with the actual represented/current color" halves stay open — they need a PalSet/Palette-id -> RGB pipeline no chargen page reads at runtime yet, judged disproportionate to add alongside this batch's other ~10 fixes. Register: AP-215 rewritten (item 2's "ordinal" framing is stale after GF-6; restated as the icon-thumbnail gap), AP-216/AP-217 rewritten (partially closed), AP-218 retired, AD-103 retired (the swallowed- child Label substitution AD-103 tracked is replaced by ValueLabel's own-geometry surfacing). 22 new tests (DatRichText unit tests, UiButton/DatWidgetFactory ValueLabel tests, live-DAT structural pins, controller behavioral tests) — all green. Full App suite (Release, live-DAT): 5300 passed / 1 pre-existing unrelated flake (PortalProjectionTests allocation test, passes in isolation) / 3 skipped, up from the baseline 5282/3. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 12 +- .../Layout/CharacterCreationAppearancePage.cs | 106 +++++++--- .../Layout/CharacterCreationHeritagePage.cs | 81 ++++++-- .../Layout/CharacterCreationProfessionPage.cs | 123 +++++++++++- .../UI/Layout/CharacterCreationSkillsPage.cs | 13 +- .../UI/Layout/CharacterCreationTownPage.cs | 17 +- src/AcDream.App/UI/Layout/DatRichText.cs | 98 +++++++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 33 ++++ src/AcDream.App/UI/UiButton.cs | 50 +++++ .../Layout/CharacterCreationLiveDatTests.cs | 154 +++++++++++++++ .../CharacterCreationUiControllerTests.cs | 187 ++++++++++++++++++ .../UI/Layout/DatRichTextTests.cs | 128 ++++++++++++ .../UI/Layout/DatWidgetFactoryTests.cs | 82 ++++++++ 13 files changed, 1018 insertions(+), 66 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/DatRichText.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 023a0877..3f22d4e9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 78 active rows (AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 77 active rows (AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -193,13 +193,12 @@ 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. **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-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) | --- -## 3. Documented approximation (AP) — 164 active rows (recount at this same edit: the row count this header carried before Batch B was already one high relative to the physical table — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change; AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 163 active rows (AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one high relative to the physical table — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change; AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -396,10 +395,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | -| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED.** What CLOSED this round: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest, retiring the prior `UiButton.Selected` highlight substitution outright (measured against the installed dat: the swatch buttons author only an unnamed DirectState sprite with no Normal/Highlight media at all, so that substitution was ALWAYS a complete no-op — the retired AP-222's own sibling finding). **Still open (unchanged, out of this round's scope):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons remains out of scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED this round; `SetStyleSpinLabel`, still open) | The ordinal still lets a player cycle deterministically and see which slot they're on through an existing widget primitive (`UiButton.Label`) rather than adding an icon-texture pipeline this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a numbered ordinal where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | -| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1).** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. acdream's swatches show only their authored (static) DAT art regardless of which color they represent or whether the current part even has that many colors — AP-215's `.Selected` substitution covers WHICH swatch is chosen, not what each swatch itself looks like. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — sets `.Selected` only, never touches swatch appearance) | The nine swatches already reach the correct SELECTION semantics through `DatWidgetFactory`'s existing `UiButton` primitive; painting each swatch with a computed color needs either a per-swatch dynamic-color render path (new UI infrastructure this scope doesn't otherwise need) or a fallback to static art, which is what this round shipped. | A side-by-side against retail shows every swatch drawing the SAME authored art regardless of which color it represents, and swatches beyond a part's real color count staying visibly "on" instead of blanking — a real visual gap on a screen the player stares at while picking a color, not a selection-correctness gap. | `gmCGAppearancePage::DoColorSpots @0x0047d850` | -| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3) — the original row misdescribed both the retail mechanism and the acdream gap.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes — the GradCircle is authored, retail-driven *decorative art reflecting the current color*, not an input control. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback and never paints it: no `BlitAndColor`-equivalent repaint on color change, and no Eyes-blank equivalent. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page never repaints it) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint (a `Blit_Multiply` composite against `m_pGradGraphic`/`m_pGradPlug`, not a click handler) is separate follow-up work with no decomp citation yet for the composite art assets. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color (and never blanking for Eyes) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | -| AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | +| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED; RE-NARROWED 2026-08-16 at Batch C fix (GF-6/AP-218) — the "1-based ordinal" framing of item 2 is now STALE and replaced below.** What CLOSED at Batch B: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest. What CLOSED at Batch C: `SetStyleSpinLabel`'s 1-based-ordinal substitution is GONE — `RefreshSpinCaptions` now writes retail's own heritage-flavored STATIC caption (see AP-218, RETIRED). **Still open (RESTATED, not the same gap the ordinal covered):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) now show the SAME static caption regardless of which style is selected — retail's own per-choice visual feedback there is an ICON THUMBNAIL this port still doesn't render (no icon-texture pipeline is wired to ANY chargen widget); the live 3D preview is the player's only feedback for which style is currently active. The four clothing spins (headgear/shirt/trousers/footwear) show a real name via `ChargenGearOption.Name` and have no icon gap. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED Batch B; `RefreshSpinCaptions`, static-caption-only, icon gap still open) | An icon-texture pipeline for the four icon-only spins is new UI infrastructure this round's scope doesn't otherwise need; the static caption alone is retail-faithful for the TEXT half. | A pixel-level side-by-side against retail would show no icon thumbnail next to the four icon-only spins' caption (cosmetic gap only — the caption text itself is now byte-correct, and the live 3D preview still shows the actual selection). A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | +| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. **What CLOSED:** the "beyond the count" half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides (`Visible=false`) any swatch index at or past the current part's own `ColorCount`, the acdream equivalent of retail's blank blit. **Still open:** the "actual color" half — acdream's swatches still show only their authored (static) DAT art regardless of which color they individually represent; painting each swatch with its own computed color needs a PalSet/Palette-id -> RGB resolution pipeline no chargen page currently reads DAT palette pixels through at runtime (new UI infrastructure this batch judged disproportionate to add alongside its ~10 other fixes). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — hides beyond-count swatches, CLOSED; still sets no per-swatch color, OPEN) | The nine swatches already reach the correct SELECTION semantics AND the correct beyond-count visibility through existing `UiButton`/`UiElement.Visible` primitives; painting each swatch with a computed color needs a genuinely new palette-to-RGB render path this batch's scope didn't otherwise need. | A side-by-side against retail shows every VALID swatch drawing the SAME authored art regardless of which color it represents — a cosmetic gap only now (the beyond-count "stuck visibly on" gap that used to mislead a player about how many real choices existed is closed). | `gmCGAppearancePage::DoColorSpots @0x0047d850` | +| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes. **What CLOSED:** the Eyes-blank half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides the GradCircle when the current part is Eyes, the acdream equivalent of the blank "grad plug" blit. **Still open:** the gradient-graphic TINT half — acdream still never repaints the GradCircle with the current part's color; that composite (`Blit_Multiply` against `m_pGradGraphic`/`m_pGradPlug`) needs the SAME palette-to-RGB resolution pipeline AP-216's still-open half needs, so it stays open for the same reason. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls` now hides the GradCircle for Eyes, CLOSED; still never repaints it for any other part, OPEN) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint is genuinely new render infrastructure, same as AP-216's open half. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color for any part OTHER than Eyes (Eyes now correctly blanks) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 92b54b49..37bd2929 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -1,4 +1,3 @@ -using System.Globalization; using AcDream.App.Rendering; using AcDream.Core.CharGen; using AcDream.Runtime; @@ -175,6 +174,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiButton? _rotateCounterClockwise; private readonly UiButton? _zoomIn; private readonly UiButton? _zoomOut; + private readonly UiElement? _gradCircle; private Choice _currentChoice = Choice.Face; private Part _currentPart = Part.Hair; @@ -242,6 +242,8 @@ internal sealed class CharacterCreationAppearancePage : IDisposable if (_shadeScroll is not null) _shadeScroll.ScalarChanged = SetShadeFromScalar; + _gradCircle = Find(pageRoot, GradCircleId); + Viewport = Find(pageRoot, ViewportId); _rotateClockwise = Find(pageRoot, RotateClockwiseId); @@ -327,8 +329,8 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } ApplyChoiceVisibility(); - if (TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) - RefreshSpins(gender, snapshot.Appearance); + // GF-6: heritage-flavored, index-independent — no gender needed. + RefreshSpinCaptions(snapshot.HeritageId); RefreshColorAndShadeControls(view, snapshot); RebuildPreview(view, snapshot); @@ -677,6 +679,37 @@ internal sealed class CharacterCreationAppearancePage : IDisposable overlay.Visible = colorSlot is not null && currentColor == (uint)i; } + // AP-216 (Campaign CC gate round 1 Batch C, PARTIAL): retail's + // DoColorSpots @0x0047d850 blits ACTUAL-color art for each valid + // swatch and BLANK art for any swatch beyond the current part's + // real color count. Painting each swatch with its own represented + // color needs a PalSet/Palette-id -> RGB resolution pipeline this + // batch does not add (no chargen page currently reads DAT palette + // pixels at runtime) — register AP-216 stays open for that half. + // This ships the cheap, fully-evidenced half: hiding a swatch a + // part's color list doesn't actually have (closest faithful + // rendering the existing pipeline supports — Visible=false is the + // acdream equivalent of "blit nothing"). + int colorCount = colorSlot is not null + && TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender) + ? ColorCount(_currentPart, swatchGender) + : 0; + for (int i = 0; i < _swatches.Length; i++) + { + if (_swatches[i] is { } swatch) + swatch.Visible = colorSlot is not null && i < colorCount; + } + + // AP-217 (PARTIAL): gmCGAppearancePage::DoGradDisk @0x0047da90 + // blits the blank "grad plug" for Eyes (DoGradDisk(this, 1), + // called from SetSelection @0x0047e85d) and a gradient graphic + // TINTED with the current part's color otherwise — the tinted + // repaint needs the same palette-to-RGB pipeline AP-216's open + // half needs, so it stays open too. This ships the evidenced + // Eyes-blank half only. + if (_gradCircle is not null) + _gradCircle.Visible = _currentPart != Part.Eyes; + ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); if (_shadeScroll is null) return; @@ -694,36 +727,57 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } } - // ── Spin labels ────────────────────────────────────────────────── + // ── Spin captions ──────────────────────────────────────────────── - private void RefreshSpins(ChargenGenderOptions gender, RuntimeCharacterCreationAppearance a) + /// + /// GF-6/AP-218 (Campaign CC gate round 1 Batch C): + /// gmCGAppearancePage::Update @ 0x0047e8f0 writes the Hair/Eyes/ + /// Skin spins' caption to a heritage-flavored STATIC string via + /// UIElement_Text::SetStringInfoWithFont — never an index or a + /// style name. Normal heritage: ID_CharGen_HairStyle/ + /// _Eyes/_Skin (@0x0047ebad/0x0047ebe3/0x0047ec6a). + /// Gearknight (heritage 6): ID_CharGen_GearText_HairButton/ + /// _EyesButton/_SkinButton (@0x0047e9ef/0x0047ea25/ + /// 0x0047eaa9). Olthoi/OlthoiAcid (heritage 0xc/0xd): + /// ID_CharGen_OlthoiText_HairButton/_EyesButton/ + /// _SkinButton (@0x0047ed5b/0x0047ed91/0x0047ee15). The other + /// six spins (Nose/Mouth/Headgear/Shirt/Trousers/Footwear) are NEVER + /// touched by Update — their DAT-authored static caption + /// (already resolved at build time by + /// DatWidgetFactory.BuildButton's own P0x17 lift) is left + /// alone. Retail shows NO per-style index or name anywhere on this + /// page — the live 3D preview is the player's only feedback for which + /// style/gear is currently selected; acdream's own prior "1-based + /// ordinal"/gear-name substitution here was never a retail behavior + /// (register AP-218, retired by this fix; AP-215's own icon-thumbnail + /// item stays open — a DIFFERENT gap, see that row's own text). + /// + private void RefreshSpinCaptions(uint heritageId) { - SetStyleSpinLabel(Part.Hair, gender.HairStyles.Count, a.HairStyle); - SetStyleSpinLabel(Part.Eyes, gender.EyeStrips.Count, a.EyesStrip); - SetStyleSpinLabel(Part.Nose, gender.NoseStrips.Count, a.NoseStrip); - SetStyleSpinLabel(Part.Mouth, gender.MouthStrips.Count, a.MouthStrip); - SetGearSpinLabel(Part.Headgear, gender.Headgears, a.HeadgearStyle); - SetGearSpinLabel(Part.Shirt, gender.Shirts, a.ShirtStyle); - SetGearSpinLabel(Part.Trousers, gender.Pants, a.TrousersStyle); - SetGearSpinLabel(Part.Footwear, gender.Footwear, a.FootwearStyle); + (string hairKey, string eyesKey, string skinKey) = heritageId switch + { + (uint)ChargenHeritageGroup.Gearknight => ( + "ID_CharGen_GearText_HairButton", + "ID_CharGen_GearText_EyesButton", + "ID_CharGen_GearText_SkinButton"), + (uint)ChargenHeritageGroup.Olthoi or (uint)ChargenHeritageGroup.OlthoiAcid => ( + "ID_CharGen_OlthoiText_HairButton", + "ID_CharGen_OlthoiText_EyesButton", + "ID_CharGen_OlthoiText_SkinButton"), + _ => ("ID_CharGen_HairStyle", "ID_CharGen_Eyes", "ID_CharGen_Skin"), + }; + + SetSpinCaption(Part.Hair, hairKey); + SetSpinCaption(Part.Eyes, eyesKey); + SetSpinCaption(Part.Skin, skinKey); } - private void SetStyleSpinLabel(Part part, int count, uint index) + private void SetSpinCaption(Part part, string key) { if (!_spins.TryGetValue(part, out UiButton? spin)) return; - spin.Label = index != Unset && index < (uint)count - ? (index + 1).ToString(CultureInfo.InvariantCulture) - : "-"; - } - - private void SetGearSpinLabel(Part part, IReadOnlyList options, uint index) - { - if (!_spins.TryGetValue(part, out UiButton? spin)) - return; - spin.Label = index != Unset && index < (uint)options.Count - ? options[(int)index].Name - : "None"; + if (_bindings.ResolveText?.Invoke(key) is { } text) + spin.Label = text; } // ── Preview rebuild ────────────────────────────────────────────── diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index 26f7eb58..7a79b465 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -72,10 +73,38 @@ internal sealed class CharacterCreationHeritagePage : IDisposable [(uint)ChargenHeritageGroup.Undead] = "ID_CharGen_UndText_BonusSkills_Trained", }; + /// + /// Root 1d (Campaign CC gate round 1 Batch C): the page's own backdrop + /// element (0x100003be, live-DAT-measured 13 authored states) + /// switches per selected heritage — gmCGHeritagePage::Update + /// @0x00483210's per-case m_pBackground->SetState(...) + /// calls (heritages 5/Shadowbound and 10/Penumbraen share literals + /// 0x10000058/0x10000059 via a shared jump target, every + /// other heritage has its own distinct state). + /// + private static readonly IReadOnlyDictionary BackdropStateByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = 0x10000021u, + [(uint)ChargenHeritageGroup.Gharundim] = 0x10000022u, + [(uint)ChargenHeritageGroup.Sho] = 0x10000023u, + [(uint)ChargenHeritageGroup.Viamontian] = 0x10000024u, + [(uint)ChargenHeritageGroup.Shadowbound] = 0x10000058u, + [(uint)ChargenHeritageGroup.Gearknight] = 0x1000005Au, + [(uint)ChargenHeritageGroup.Tumerok] = 0x1000005Fu, + [(uint)ChargenHeritageGroup.Lugian] = 0x10000060u, + [(uint)ChargenHeritageGroup.Empyrean] = 0x1000005Cu, + [(uint)ChargenHeritageGroup.Penumbraen] = 0x10000059u, + [(uint)ChargenHeritageGroup.Undead] = 0x1000005Bu, + [(uint)ChargenHeritageGroup.Olthoi] = 0x1000005Du, + [(uint)ChargenHeritageGroup.OlthoiAcid] = 0x1000005Eu, + }; + private readonly CharacterCreationRuntimeBindings _bindings; private readonly Action _onButtonClicked; private readonly Dictionary _buttons = []; private readonly UiText? _description; + private readonly UiElement? _backdrop; private bool _disposed; /// Review fix round F3 (2026-08-15): @@ -105,6 +134,7 @@ internal sealed class CharacterCreationHeritagePage : IDisposable } _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; + _backdrop = UiElement.FindDescendant(pageRoot, 0x100003BEu); } internal void Refresh( @@ -114,12 +144,24 @@ internal sealed class CharacterCreationHeritagePage : IDisposable foreach ((UiButton button, uint heritageId) in _buttons) button.Selected = heritageId == snapshot.HeritageId; + // Root 1d: switch the backdrop art per selected heritage. Retail + // runs this unconditionally alongside the button highlight/text + // composition below — no heritage-unset guard exists in the decomp + // beyond the dictionary lookup itself (heritageId 0 simply has no + // entry, so TryGetValue leaves the backdrop at whatever state it + // last held, matching retail's own "no case 0" switch shape). + if (_backdrop is IUiDatStateful backdropStateful + && BackdropStateByHeritage.TryGetValue(snapshot.HeritageId, out uint backdropState)) + { + backdropStateful.TrySetRetailState(backdropState); + } + if (_description is null) return; - string composed = ComposeDescription(view, snapshot.HeritageId, _bindings.ResolveText); - _description.LinesProvider = () => - [new UiText.Line(composed, _description.DefaultColor)]; + IReadOnlyList segments = ComposeSegments( + _description, view, snapshot.HeritageId, _bindings.ResolveText); + _description.LinesProvider = () => DatRichText.Compose(_description, segments); } internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) @@ -168,38 +210,45 @@ internal sealed class CharacterCreationHeritagePage : IDisposable /// body, the bonus-skills header, then — only once a heritage is /// selected — that heritage's own bonus-skills line (absent for /// Lugian/Olthoi/OlthoiAcid; see ). - /// is the DAT string lookup - /// (RetailUiRuntime's DatStringResolver over table - /// 0x23000002) threaded through the bindings record; a missing - /// resolver or a missing key degrades to skipping that segment rather - /// than throwing. + /// Header segments use SetStringInfoWithFont's own font-index + /// argument (1 — palette index 1, live-DAT-measured GREEN); + /// body/bonus-body segments use index 0 (white). is the DAT string lookup (RetailUiRuntime's + /// DatStringResolver over table 0x23000002) threaded + /// through the bindings record; a missing resolver or a missing key + /// degrades to skipping that segment rather than throwing. /// - private static string ComposeDescription( + private static IReadOnlyList ComposeSegments( + UiText description, IRuntimeCharacterCreationView view, uint heritageId, Func? resolveText) { + Vector4 headerColor = DatRichText.PaletteColor(description, 1, new Vector4(0f, 1f, 0f, 1f)); + Vector4 bodyColor = DatRichText.PaletteColor(description, 0, Vector4.One); + if (resolveText is null) { - return view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named) + string name = view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named) ? named.Name : string.Empty; + return [new DatRichText.Segment(name, bodyColor)]; } - var parts = new List(); + var segments = new List(); if (resolveText("ID_CharGen_Heritage_StartingSkills_Header") is { } header) - parts.Add(header); + segments.Add(new(header, headerColor)); if (resolveText("ID_CharGen_Heritage_StartingSkills") is { } body) - parts.Add(body); + segments.Add(new(body, bodyColor)); if (resolveText("ID_CharGen_Heritage_BonusSkills_Trained_Header") is { } bonusHeader) - parts.Add(bonusHeader); + segments.Add(new(bonusHeader, headerColor)); if (heritageId != 0 && BonusSkillsKeyByHeritage.TryGetValue(heritageId, out string? bonusKey) && resolveText(bonusKey) is { } bonusBody) { - parts.Add(bonusBody); + segments.Add(new(bonusBody, bodyColor)); } - return string.Join("\n\n", parts); + return segments; } public void Dispose() diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs index e5ef6434..36a69d64 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs @@ -69,6 +69,65 @@ internal sealed class CharacterCreationProfessionPage : IDisposable private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value); + /// + /// GF-4b: the six slider containers' name-label CHILD, relative id + /// 0x100002edgmCGProfessionPage::InitializePage + /// @0x00482e1a-0x00482f1d writes CharGenState::GetAttributeName + /// @0x005C3A20's literal ONCE at page construction (no per-refresh + /// rewrite anywhere in the decomp — UpdateAttributeValues only + /// touches pSlider/pAttribValue, never this id). Live-DAT- + /// measured: this child resolves as Type 1 (UIElement_Button), + /// matching retail's own declared UIElement_Button* field type + /// that still accepts UIElement_Text::SetText — retail's button + /// class carries the same text-rendering capability + /// already is in this port. + /// + private const uint SliderNameRelativeId = 0x100002EDu; + + /// + /// GF-3: the description textbox — gmCGProfessionPage::InitializePage + /// @0x00483068's m_pTextBox. + /// + private const uint DescriptionTextId = 0x100003E0u; + + /// + /// Root 1d: the page's own backdrop (0x100003d8, live-DAT- + /// measured 7 authored states) switches per selected template — + /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case + /// eax_2->SetState(...) calls, keyed by ChargenTemplate + /// index (0=Custom..6=Soldier), NOT the button-id map above. + /// + private static readonly IReadOnlyDictionary BackdropStateByTemplate = + new Dictionary + { + [0u] = 0x1000002Bu, // Custom / Adventurer + [1u] = 0x1000002Cu, // Bow Hunter + [2u] = 0x10000031u, // Swashbuckler + [3u] = 0x1000002Du, // Life Caster + [4u] = 0x1000002Eu, // War Caster + [5u] = 0x1000002Fu, // Wayfarer + [6u] = 0x10000030u, // Soldier + }; + + /// + /// GF-3: per-template description string id — + /// gmCGProfessionPage::UpdateProfession @0x00482203-0048233d's + /// per-case var_a4_1 literal, resolved through + /// UIElement_Text::SetStringInfo (NOT ...WithFont — a single + /// plain string, no per-run palette color). + /// + private static readonly IReadOnlyDictionary DescriptionKeyByTemplate = + new Dictionary + { + [0u] = "ID_CharGen_CustomText", + [1u] = "ID_CharGen_BowText", + [2u] = "ID_CharGen_SwashText", + [3u] = "ID_CharGen_LifeText", + [4u] = "ID_CharGen_WarText", + [5u] = "ID_CharGen_WayText", + [6u] = "ID_CharGen_SoldierText", + }; + private readonly CharacterCreationRuntimeBindings _bindings; private readonly Dictionary _templateButtons = []; private readonly Dictionary _sliders = []; @@ -76,6 +135,8 @@ internal sealed class CharacterCreationProfessionPage : IDisposable private readonly UiButton? _healthValue; private readonly UiButton? _staminaValue; private readonly UiButton? _manaValue; + private readonly UiText? _description; + private readonly UiElement? _backdrop; private bool _disposed; internal CharacterCreationProfessionPage( @@ -118,22 +179,29 @@ internal sealed class CharacterCreationProfessionPage : IDisposable value.OnSubmit = text => SetAttributeFromText(capturedAttribute, text); } + // GF-4b: the name-label child is static per attribute — retail + // writes it exactly once (InitializePage), never on refresh. + if (UiElement.FindDescendant(container, SliderNameRelativeId) is UiButton nameLabel) + nameLabel.Label = AttributeName(attribute); + _sliders[attribute] = new SliderWidgets(lockButton, slider, value); } - // Live-DAT probe (CharacterCreationLiveDatTests): every one of the - // four display containers (0x100003e2..e5) authors as a Button - // whose Type-12 value child (0x100002f1/0x100002f3) is swallowed by - // UiButton.ConsumesDatChildren — the same "consumed child -> use - // the button's own Label" substitution the Skills page's credits - // meter needed (see CharacterCreationSkillsPage's ctor comment). - // Retail's own DynamicCast(0xc) on the CHILD (not the container) - // still stands as ground truth for the container's ROLE; only - // acdream's widget-level addressability differs (register AD-103). + // GF-4a (Campaign CC gate round 1 Batch C): every one of the four + // display buttons (0x100003e2..e5) authors its CAPTION directly as + // its own P0x17 and carries a SEPARATE, media-less Type-12 value + // child (0x100002f1/0x100002f3 — gmCGProfessionPage::InitializePage + // @0x00482f90-0x00483062). DatWidgetFactory.BuildButton now surfaces + // that child as UiButton.ValueLabel, coexisting with the authored + // Label caption — see that method's own doc comment. Retiring the + // prior Label-clobber substitution (register AD-103). _availableValue = UiElement.FindDescendant(pageRoot, 0x100003E2u) as UiButton; _healthValue = UiElement.FindDescendant(pageRoot, 0x100003E3u) as UiButton; _staminaValue = UiElement.FindDescendant(pageRoot, 0x100003E4u) as UiButton; _manaValue = UiElement.FindDescendant(pageRoot, 0x100003E5u) as UiButton; + + _description = UiElement.FindDescendant(pageRoot, DescriptionTextId) as UiText; + _backdrop = UiElement.FindDescendant(pageRoot, 0x100003D8u); } internal void Refresh( @@ -173,6 +241,23 @@ internal sealed class CharacterCreationProfessionPage : IDisposable SetDisplay(_healthValue, endurance / 2); SetDisplay(_staminaValue, endurance); SetDisplay(_manaValue, snapshot.Attributes.Self); + + // Root 1d: backdrop art per selected template. + if (_backdrop is IUiDatStateful backdropStateful + && BackdropStateByTemplate.TryGetValue(snapshot.Template, out uint backdropState)) + { + backdropStateful.TrySetRetailState(backdropState); + } + + // GF-3: description textbox — one plain segment (SetStringInfo, + // not ...WithFont), so a single DefaultColor run. + if (_description is not null + && DescriptionKeyByTemplate.TryGetValue(snapshot.Template, out string? key)) + { + string? text = _bindings.ResolveText?.Invoke(key); + var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) }; + _description.LinesProvider = () => DatRichText.Compose(_description, segments); + } } internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) @@ -205,9 +290,27 @@ internal sealed class CharacterCreationProfessionPage : IDisposable { if (display is null) return; - display.Label = value.ToString(CultureInfo.InvariantCulture); + // GF-4a: the button's OWN P0x17 caption ("Attribute Credits" etc.) + // stays in Label; the live number goes in the coexisting value + // slot DatWidgetFactory.BuildButton surfaced from the button's + // media-less Type-12 child. + display.ValueLabel = value.ToString(CultureInfo.InvariantCulture); } + /// Ports CharGenState::GetAttributeName @ 0x005C3A20 + /// verbatim — retail hardcodes these six literals directly (not a + /// DAT/localization lookup), so this port does too. + private static string AttributeName(ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => "Strength", + ChargenAttributeId.Endurance => "Endurance", + ChargenAttributeId.Quickness => "Quickness", + ChargenAttributeId.Coordination => "Coordination", + ChargenAttributeId.Focus => "Focus", + ChargenAttributeId.Self => "Self", + _ => string.Empty, + }; + private void SelectTemplate(uint templateIndex) { if (_disposed) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index 1ab988c3..2bb022a8 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -126,10 +126,13 @@ internal sealed class CharacterCreationSkillsPage : IDisposable // the mechanism our factory already uses to surface a consumed // Type-12 child's text (register AD-103). // - // GF-5 note: this clobbers the button's authored "Available Skill - // Credits" caption (retail's own m_pCreditsMeter is a SEPARATE - // widget from any caption text) — left as-is per the gate-round - // scope (Batch C owns the caption fix). + // GF-4a (Campaign CC gate round 1 Batch C): this used to clobber + // the button's authored "Available Skill Credits" caption (retail's + // own m_pCreditsMeter, 0x100002f3, is a SEPARATE widget from the + // caption text — gmCGSkillsPage::InitializePage @0x00481e1c). + // DatWidgetFactory.BuildButton now surfaces that media-less Type-12 + // child as UiButton.ValueLabel, coexisting with Label — see + // Refresh below. _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; @@ -150,7 +153,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable RefreshRowValues(row, view, snapshot); if (_credits is { } credits) - credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); + credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); } private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs index e296d907..f2b1be7d 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs @@ -103,9 +103,22 @@ internal sealed class CharacterCreationTownPage : IDisposable if (_description is null) return; + // GF-11a: gmCGTownPage::SetTownString @ 0x0047c1f0 concatenates + // howTo + a compiled "\n\n%s\n" literal format around the town + // text (the ONE composition site in this batch where retail's OWN + // code — not the authored DAT string content — inserts the blank + // line, confirmed via the format string's raw bytes, + // 0x0079c2d2 = u"\n\n%s\n") into ONE plain SetText — no per-run + // font/color argument, unlike Heritage's WithFont calls. The + // string composition itself was already byte-correct before this + // fix; what was missing was routing it through the same + // escape-normalize + word-wrap path every other description box + // needed (a single un-wrapped line meant the town-specific suffix + // rendered past the clipped viewport, which is why switching towns + // looked like the text never changed). string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText); - _description.LinesProvider = () => - [new UiText.Line(composed, _description.DefaultColor)]; + var segments = new[] { new DatRichText.Segment(composed, _description.DefaultColor) }; + _description.LinesProvider = () => DatRichText.Compose(_description, segments); } internal void Randomize(IRuntimeCharacterCreationView view) diff --git a/src/AcDream.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs new file mode 100644 index 00000000..c55f1476 --- /dev/null +++ b/src/AcDream.App/UI/Layout/DatRichText.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// Shared multi-segment rich-text composer for the chargen description +/// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the +/// Summary how-to text). Ports retail's +/// UIElement_Text::SetStringInfoWithFont / +/// AppendStringInfoWithFont @ 0x00469D70 composition model: a text +/// box is built from an ORDERED list of string segments, each carrying its +/// OWN font-color palette index +/// (UIElement_Text::AppendStringInfoWithFont's +/// SetFontColorHelper -> InqProperty(0x1B) array lookup — +/// see ). +/// +/// +/// The description pages used to bypass this entirely: they assigned a raw +/// LinesProvider lambda returning ONE unwrapped +/// per composed string, with no escape-normalize and no word-wrap. Two +/// concrete symptoms this caused: literal two-character "\n" +/// escapes rendered as backslash-n instead of a real line break (the DAT +/// stores that literal escape — DatWidgetFactory.BuildText's own +/// authored-string path already normalizes it for single-element authored +/// captions; this helper reproduces the SAME normalize for +/// runtime-composed multi-segment text), and — for the Town page +/// specifically — an unwrapped single line meant the town-specific SUFFIX +/// of the composed string rendered far outside the box's clipped viewport, +/// so switching towns looked like "the text never changes" even though the +/// underlying string genuinely did (only its INVISIBLE tail differed). +/// +/// +internal static class DatRichText +{ + /// One composed segment: text plus the color it should render + /// in. A null or empty is silently skipped (mirrors + /// retail's own null-string-info no-op guards throughout this text + /// composition family). + public readonly record struct Segment(string? Text, Vector4 Color); + + /// + /// Escape-normalizes and word-wraps every segment (independently, so + /// each segment's wrapped lines keep ITS OWN color), then concatenates + /// the results in order. No separator is inserted between segments — + /// retail's own composition calls concatenate directly + /// (AppendStringInfoWithFont/append_n_chars with no + /// interposed literal), so any blank-line spacing between sections + /// comes from the authored DAT string content itself, not from code + /// here. + /// + public static IReadOnlyList Compose( + UiText target, + IReadOnlyList segments) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(segments); + + var lines = new List(); + float maximumWidth = MathF.Max(1f, target.Width - 2f * target.Padding); + Func measure = target.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + + foreach (Segment segment in segments) + { + if (string.IsNullOrEmpty(segment.Text)) + continue; + + // The installed DAT stores the LITERAL two-character escape + // "\n" (0x5C 0x6E), not a real line break — same normalize + // DatWidgetFactory.BuildText's authored-string path already + // applies for single-element authored captions. + string normalized = segment.Text + .Replace("\\n", "\n") + .Replace("\r", string.Empty); + + foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth)) + lines.Add(new UiText.Line(wrapped, segment.Color)); + } + + return lines; + } + + /// + /// Resolves 's own authored font-color + /// palette (dat property 0x1B) entry at , + /// falling back to when the palette is + /// absent or too short. Mirrors the same fallback shape + /// CharacterStatController.BuildSelectedTitleRuns already uses + /// for its own palette-indexed colors. + /// + public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) => + index >= 0 && index < target.FontColorPalette.Count + ? target.FontColorPalette[index] + : fallback; +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index bf4df604..a2e4b1ea 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -933,6 +933,39 @@ public static class DatWidgetFactory ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu), ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u)); + // GF-4a (Campaign CC gate round 1 Batch C): retail's chargen + // display buttons author the caption directly as THEIR OWN P0x17 + // (so `label` above resolved from `info` itself, not a lifted + // child) AND carry a SEPARATE, media-less Type-12 child for the + // live value (gmCGProfessionPage::InitializePage + // @0x00482f90-0x00483062, gmCGSkillsPage::InitializePage + // @0x00481e1c — live-DAT-measured: exactly one Type-12 child, zero + // StateMedia entries). Gated tightly to that exact shape so this + // stays a no-op for every other button (a lifted-caption button + // never reaches here with labelInfo==info; a button with an icon/ + // face child instead of a value child has no media-less Type-12 + // child to find). + if (ReferenceEquals(labelInfo, info) && label is not null) + { + ElementInfo? valueChild = info.Children.FirstOrDefault( + child => child.Type == 12u && child.StateMedia.Count == 0); + if (valueChild is not null) + { + button.ValueBox = (valueChild.X, valueChild.Y, valueChild.Width, valueChild.Height); + button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null + ? fontResolve(valueChild.FontDid) ?? elementFont + : elementFont; + button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One; + button.ValueAlign = valueChild.HJustify == HJustify.Left + ? UiButton.LabelAlignment.Left + : UiButton.LabelAlignment.Center; + // Seed with whatever the child itself authors (typically + // blank) so an unbound button doesn't draw stray leftover + // text before a controller writes a real value. + button.ValueLabel = ResolveAuthoredString(valueChild, stringResolve); + } + } + return button; } diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 41146914..2cd171ab 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -179,6 +179,43 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// public (float X, float Y, float Width, float Height)? LabelBox { get; set; } + /// + /// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE + /// text, coexisting with (the authored CAPTION). + /// Retail's chargen display buttons (Attribute/Skill Credits, Health, + /// Stamina, Mana — 0x100003e2-e5, 0x100003f9) author the + /// caption directly as this element's own dat property 0x17 + /// AND carry a SEPARATE, media-less Type-12 child for the live value + /// (gmCGProfessionPage::InitializePage @0x00482f90-0x00483062, + /// gmCGSkillsPage::InitializePage @0x00481e1c) — + /// consumes ALL of its dat children + /// (), which used to mean a page + /// controller had nowhere faithful to put the value except + /// overwriting itself, destroying the caption. + /// now surfaces that + /// child's geometry/font/color here instead. Null (default) draws + /// nothing extra — every pre-existing button that only ever wrote + /// is unaffected. + /// + public string? ValueLabel { get; set; } + + /// Dat font for . + public UiDatFont? ValueFont { get; set; } + + /// Color for (default white). + public Vector4 ValueColor { get; set; } = Vector4.One; + + /// Authored rectangle for , LOCAL to + /// this button — the lifted value child's own rect + /// ( sets this). Null + /// (no value child found) means is never set + /// either, so this is never read in that case. + public (float X, float Y, float Width, float Height)? ValueBox { get; set; } + + /// Horizontal alignment of within + /// — the lifted child's own authored justify. + public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center; + /// Label horizontal alignment options. public enum LabelAlignment { Center, Left } @@ -453,6 +490,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor); } + if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf) + { + float boxX = ValueBox?.X ?? 0f; + float boxY = ValueBox?.Y ?? 0f; + float boxWidth = ValueBox?.Width ?? Width; + float boxHeight = ValueBox?.Height ?? Height; + float vx = ValueAlign == LabelAlignment.Left + ? boxX + LabelOffsetX + : boxX + (boxWidth - vf.MeasureWidth(value)) * 0.5f; + float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f; + ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor); + } + uint dragSprite = _itemDragAcceptance switch { ItemDragAcceptance.Accept => ItemDragAcceptSprite, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 88cb63ff..a9664b8a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1053,6 +1053,160 @@ public sealed class CharacterCreationLiveDatTests Assert.True(okButton.Width > 0f && okButton.Height > 0f); } + /// + /// Campaign CC gate round 1 Batch C (GF-4a). Live-DAT-measured: each of + /// the four Profession display buttons (avail/health/stamina/mana + /// credits) and the Skills credits button author the CAPTION directly + /// as their OWN P0x17 property and carry exactly ONE Type-12 child with + /// NO state media of its own — the live VALUE slot + /// (gmCGProfessionPage::InitializePage @0x00482f90-0x00483062, + /// gmCGSkillsPage::InitializePage @0x00481e1c). Pins the shape + /// 's ValueLabel + /// detection depends on. + /// + [InstalledDatFact] + public void ProfessionAndSkillsDisplayButtons_OwnCaptionPlusOneMediaLessValueChild() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + (uint Button, uint ValueChild)[] shapes = + [ + (0x100003E2u, 0x100002F1u), // Profession available attribute credits + (0x100003E3u, 0x100002F3u), // Profession health + (0x100003E4u, 0x100002F3u), // Profession stamina + (0x100003E5u, 0x100002F3u), // Profession mana + (0x100003F9u, 0x100002F3u), // Skills credits + ]; + foreach ((uint buttonId, uint valueChildId) in shapes) + { + ElementInfo button = Assert.IsType(FindInfo(rootInfo, buttonId)); + Assert.Equal(1u, button.Type); + Assert.True( + button.TryGetEffectiveProperty(0x17u, out UiPropertyValue caption) + && caption.Kind == UiPropertyKind.StringInfo, + $"button 0x{buttonId:X8} must author its own P0x17 caption."); + ElementInfo singleChild = Assert.Single(button.Children); + Assert.Equal(valueChildId, singleChild.Id); + Assert.Equal(12u, singleChild.Type); + Assert.Empty(singleChild.StateMedia); + } + } + + /// + /// GF-4b: the Profession page's six slider containers each carry a + /// name-label CHILD at the SAME relative id (0x100002ed) — + /// live-DAT-measured as Type 1 (UIElement_Button), matching + /// retail's own declared pointer type + /// (class UIElement_Button* m_pHairSpin-shaped fields + /// throughout gmCGAppearancePage/gmCGProfessionPage that + /// still receive UIElement_Text::SetText calls — retail's + /// UIElement_Button is DynamicCast-compatible with + /// UIElement_Text (id 0xc), i.e. buttons carry their own + /// text-rendering capability). acdream's UiButton.Label is that + /// exact capability, so this element resolves as + /// in our port too, not . + /// + [InstalledDatFact] + public void ProfessionPage_SliderContainers_HaveNameLabelButtonChild() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + UiElement professionRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProfessionPageElementId)); + + foreach (uint containerId in new[] + { + 0x100003E6u, 0x100003E7u, 0x100003E8u, + 0x100003E9u, 0x100003EAu, 0x100003EBu, + }) + { + UiElement container = Assert.IsAssignableFrom( + UiElement.FindDescendant(professionRoot, containerId)); + Assert.IsType(UiElement.FindDescendant(container, 0x100002EDu)); + } + } + + /// + /// Root 1d (Campaign CC gate round 1 Batch C): the Heritage + /// (0x100003be, 13 states) and Profession (0x100003d8, + /// 7 states) backdrops, live-DAT-measured against + /// gmCGHeritagePage::Update's m_pBackground->SetState + /// literals and gmCGProfessionPage::UpdateProfession's + /// per-template eax_2->SetState literals. + /// + [InstalledDatFact] + public void HeritageAndProfessionBackdrops_AuthorEveryRetailState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + ElementInfo heritageBackdrop = Assert.IsType(FindInfo(rootInfo, 0x100003BEu)); + uint[] heritageStates = + [ + 0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u, + 0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, + 0x1000005Eu, 0x1000005Fu, 0x10000060u, + ]; + foreach (uint stateId in heritageStates) + Assert.True(heritageBackdrop.States.ContainsKey(stateId), $"heritage backdrop missing state 0x{stateId:X8}"); + + ElementInfo professionBackdrop = Assert.IsType(FindInfo(rootInfo, 0x100003D8u)); + uint[] professionStates = + [ + 0x1000002Bu, 0x1000002Cu, 0x1000002Du, + 0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u, + ]; + foreach (uint stateId in professionStates) + Assert.True(professionBackdrop.States.ContainsKey(stateId), $"profession backdrop missing state 0x{stateId:X8}"); + } + + /// + /// GF-3: the Profession page's description textbox + /// (0x100003e0) resolves as and (Commit-2 + /// scope, pinned here for completeness) carries the SAME eight + /// gold-frame child ids the Town description (0x10000409) and + /// Summary how-to (0x10000404) boxes carry — one shared box + /// template reused across pages. + /// + [InstalledDatFact] + public void DescriptionTextboxes_ShareTheSameGoldFrameChildTemplate() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + uint[] frameChildIds = + [ + 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, + 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, + ]; + foreach (uint boxId in new[] { 0x100003E0u, 0x10000409u, 0x10000404u }) + { + ElementInfo box = Assert.IsType(FindInfo(rootInfo, boxId)); + Assert.Equal(12u, box.Type); + foreach (uint frameChildId in frameChildIds) + Assert.Contains(box.Children, c => c.Id == frameChildId); + } + // The Summary how-to box additionally carries a linked scrollbar. + ElementInfo summaryHowTo = Assert.IsType(FindInfo(rootInfo, 0x10000404u)); + Assert.Contains(summaryHowTo.Children, c => c.Id == 0x100002E7u); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 7d949070..91b98caf 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1505,6 +1505,164 @@ public sealed class CharacterCreationUiControllerTests public void RotateCounterClockwise() => RotateCounterClockwiseCalls++; } + // ── Campaign CC gate round 1 Batch C ──────────────────────────────── + + /// + /// GF-2: the composed description routes through the shared rich-text + /// helper — header segments (palette index 1) render in a DIFFERENT + /// color than body segments (index 0), and each segment's own escape + /// sequence is normalized. The fixture's description element carries + /// no authored FontColorPalette, so this also exercises + /// 's fallback (green header / + /// white body). + /// + [Fact] + public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:"; + environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\\nLine two"; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + BumpRevisionAndTick(environment); + + UiText description = Assert.IsType(environment.Screen.FindElement(0x100003C4u)); + var lines = description.LinesProvider().ToList(); + + Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f)); + // The literal "\n" escape in the body segment must become TWO + // separate lines, not render as a literal backslash-n. + Assert.Contains(lines, l => l.Text == "Line one" && l.Color == Vector4.One); + Assert.Contains(lines, l => l.Text == "Line two" && l.Color == Vector4.One); + Assert.DoesNotContain(lines, l => l.Text.Contains("\\n")); + } + + /// GF-11a: switching towns changes the RENDERED (wrapped) + /// lines, not just an internal string that never becomes visible — + /// the diagnosed root cause of "description does not change" was a + /// single un-wrapped line whose differing suffix rendered past the + /// clipped viewport. + [Fact] + public void TownDescription_ChangesRenderedLinesWhenSwitchingTowns() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_TownHowTo"] = "How to pick a town."; + environment.Runtime.ResolvedStrings["ID_CharGen_HoltText"] = "Holtburg is snowy."; + environment.Runtime.ResolvedStrings["ID_CharGen_ShoushiText"] = "Shoushi is sunny."; + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!(); + + environment.Button(0x1000040Du).OnClick!(); // Holtburg + BumpRevisionAndTick(environment); + UiText description = Assert.IsType(environment.Screen.FindElement(0x10000409u)); + string holtburgText = JoinedText(description); + Assert.Contains("Holtburg is snowy.", holtburgText); + + environment.Button(0x1000040Fu).OnClick!(); // Shoushi + BumpRevisionAndTick(environment); + string shoushiText = JoinedText(description); + Assert.Contains("Shoushi is sunny.", shoushiText); + Assert.DoesNotContain("Holtburg is snowy.", shoushiText); + } + + /// GF-3: the Profession page's description textbox + /// (0x100003e0) binds and switches per selected template. + [Fact] + public void ProfessionDescription_BindsAndSwitchesPerTemplate() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_CustomText"] = "Custom flexible build."; + environment.Runtime.ResolvedStrings["ID_CharGen_BowText"] = "Bow hunters use ranged attacks."; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!(); + + environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1 + BumpRevisionAndTick(environment); + + UiText description = Assert.IsType(environment.Screen.FindElement(0x100003E0u)); + Assert.Contains("Bow hunters use ranged attacks.", JoinedText(description)); + } + + /// GF-4a: the display buttons' authored caption survives a + /// value write — the whole point of the ValueLabel coexistence + /// mechanism. + [Fact] + public void ProfessionAndSkillsDisplayButtons_ValueWriteDoesNotClobberLabel() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + + UiButton available = environment.Button(0x100003E2u); + available.Label = "Attribute Credits"; // fixture authors no P0x17; simulate it + UiButton credits = environment.Button(0x100003F9u); + credits.Label = "Available Skill Credits"; + + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + Assert.Equal("Attribute Credits", available.Label); + // The fixture's default snapshot (BuildOptions' companion default) + // carries RemainingAttributeCredits=66 — an exact, non-vacuous + // pin, not just "some value got written somewhere". + Assert.Equal("66", available.ValueLabel); + + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + Assert.Equal("Available Skill Credits", credits.Label); + Assert.Equal("50", credits.ValueLabel); // RemainingSkillCredits=50 + } + + /// GF-6/AP-218: the Appearance page's Hair/Eyes/Skin spins + /// show a heritage-flavored STATIC caption, never a numeric ordinal — + /// and switch to the Gearknight/Olthoi variant per heritage. + [Fact] + public void AppearanceSpinCaptions_ArePartNames_NotOrdinals_AndVaryByHeritage() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_HairStyle"] = "Hair Style"; + environment.Runtime.ResolvedStrings["ID_CharGen_Eyes"] = "Eyes"; + environment.Runtime.ResolvedStrings["ID_CharGen_GearText_HairButton"] = "Gear Hair"; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + + UiButton hairSpin = environment.Button(CharacterCreationAppearancePage.HairSpinId); + Assert.Equal("Hair Style", hairSpin.Label); + Assert.DoesNotContain(hairSpin.Label, new[] { "1", "2", "-" }); + + environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gearknight); + BumpRevisionAndTick(environment); + Assert.Equal("Gear Hair", hairSpin.Label); + } + + /// Root 1d: the Heritage and Profession backdrops switch + /// state per selection. + [Fact] + public void HeritageAndProfessionBackdrops_SwitchStatePerSelection() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + var heritageBackdrop = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003BEu)); + environment.Runtime.SelectHeritageDirect(AluvianId); + BumpRevisionAndTick(environment); + Assert.Equal(0x10000021u, heritageBackdrop.ActiveRetailStateId); + + environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gharundim); + BumpRevisionAndTick(environment); + Assert.Equal(0x10000022u, heritageBackdrop.ActiveRetailStateId); + + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!(); + var professionBackdrop = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003D8u)); + environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1 + BumpRevisionAndTick(environment); + Assert.Equal(0x1000002Cu, professionBackdrop.ActiveRetailStateId); + } + private static void BumpRevisionAndTick(EnvironmentHarness environment) { RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; @@ -2113,6 +2271,20 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi page.Children.Add(ButtonInfo(0x100005F1u)); // Lugian (F3 quirk: no tab-restore/hide) page.Children.Add(TextInfo(0x100003C4u)); + + // Root 1d: the backdrop, live-DAT-measured 13 authored states + // (see CharacterCreationHeritagePage.BackdropStateByHeritage). + var backdrop = ContainerInfo(0x100003BEu); + foreach (uint stateId in new[] + { + 0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u, + 0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, + 0x1000005Eu, 0x1000005Fu, 0x10000060u, + }) + { + backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" }; + } + page.Children.Add(backdrop); return page; } @@ -2138,6 +2310,21 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ButtonInfo(0x100003E3u)); // Health page.Children.Add(ButtonInfo(0x100003E4u)); // Stamina page.Children.Add(ButtonInfo(0x100003E5u)); // Mana + + page.Children.Add(TextInfo(0x100003E0u)); // GF-3: description textbox + + // Root 1d: the backdrop, live-DAT-measured 7 authored states (see + // CharacterCreationProfessionPage.BackdropStateByTemplate). + var backdrop = ContainerInfo(0x100003D8u); + foreach (uint stateId in new[] + { + 0x1000002Bu, 0x1000002Cu, 0x1000002Du, + 0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u, + }) + { + backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" }; + } + page.Children.Add(backdrop); return page; } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs new file mode 100644 index 00000000..4d94a819 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs @@ -0,0 +1,128 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC gate round 1 Batch C: unit tests for the shared +/// escape-normalize + word-wrap + per-segment-color helper feeding +/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3). +/// +public class DatRichTextTests +{ + private static readonly Vector4 White = Vector4.One; + private static readonly Vector4 Green = new(0f, 1f, 0f, 1f); + + private static UiText MakeTarget(float width) => + new() { Width = width, Height = 200f }; + + [Fact] + public void Compose_NormalizesLiteralBackslashNEscape() + { + UiText target = MakeTarget(1000f); // wide enough that nothing wraps + var segments = new[] { new DatRichText.Segment("line one\\nline two", White) }; + + var lines = DatRichText.Compose(target, segments); + + Assert.Equal(2, lines.Count); + Assert.Equal("line one", lines[0].Text); + Assert.Equal("line two", lines[1].Text); + } + + [Fact] + public void Compose_WordWrapsToTheTargetWidth() + { + // Bitmap-font-shaped measure: 8px/char, matching BuildText's own + // authored-string fallback measure. + UiText target = MakeTarget(80f); // 10 chars per line at 8px/char + var segments = new[] + { + new DatRichText.Segment("one two three four five six seven eight", White), + }; + + var lines = DatRichText.Compose(target, segments); + + Assert.True(lines.Count > 1, "a long segment must wrap to more than one line"); + foreach (UiText.Line line in lines) + Assert.True(line.Text.Length * 8f <= 80f, $"line '{line.Text}' overflowed the target width"); + } + + [Fact] + public void Compose_EachSegmentKeepsItsOwnColorAcrossItsWrappedLines() + { + UiText target = MakeTarget(1000f); + var segments = new[] + { + new DatRichText.Segment("Header:", Green), + new DatRichText.Segment("Body text.", White), + }; + + var lines = DatRichText.Compose(target, segments); + + Assert.Equal(2, lines.Count); + Assert.Equal(Green, lines[0].Color); + Assert.Equal(White, lines[1].Color); + } + + [Fact] + public void Compose_NullOrEmptySegmentText_IsSkipped() + { + UiText target = MakeTarget(1000f); + var segments = new[] + { + new DatRichText.Segment(null, White), + new DatRichText.Segment(string.Empty, White), + new DatRichText.Segment("real text", White), + }; + + var lines = DatRichText.Compose(target, segments); + + Assert.Single(lines); + Assert.Equal("real text", lines[0].Text); + } + + [Fact] + public void Compose_NoSeparatorInsertedBetweenSegments() + { + // Retail's own composition calls concatenate directly + // (AppendStringInfoWithFont / append_n_chars, no interposed + // literal) — this helper must not invent one either. + UiText target = MakeTarget(1000f); + var segments = new[] + { + new DatRichText.Segment("first", White), + new DatRichText.Segment("second", White), + }; + + var lines = DatRichText.Compose(target, segments); + + // Each segment still wraps independently (so "first"/"second" stay + // on separate output lines, not glued into "firstsecond") — but no + // BLANK line is inserted between them unless the segment's own + // text carried one. + Assert.Equal(2, lines.Count); + Assert.Equal("first", lines[0].Text); + Assert.Equal("second", lines[1].Text); + } + + [Fact] + public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent() + { + UiText target = new() + { + FontColorPalette = [White, Green], + }; + + Assert.Equal(White, DatRichText.PaletteColor(target, 0, Green)); + Assert.Equal(Green, DatRichText.PaletteColor(target, 1, White)); + } + + [Fact] + public void PaletteColor_FallsBack_WhenPaletteTooShortOrMissing() + { + UiText target = new(); // empty palette + + Assert.Equal(Green, DatRichText.PaletteColor(target, 1, Green)); + Assert.Equal(Green, DatRichText.PaletteColor(target, -1, Green)); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 9c428648..e9066456 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -456,6 +456,88 @@ public class DatWidgetFactoryTests Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4 } + /// + /// GF-4a (Campaign CC gate round 1 Batch C): retail's chargen display + /// buttons (live-DAT-measured shape) author their CAPTION directly as + /// their own P0x17 AND carry one SEPARATE, media-less Type-12 child for + /// the live value. BuildButton surfaces that child through + /// / + /// instead of dropping it — coexisting with, not clobbering, + /// . + /// + [Fact] + public void BuildButton_OwnCaptionPlusMediaLessTextChild_SurfacesValueSlotWithoutClobberingLabel() + { + uint captionStringId = 111u; + var info = new ElementInfo { Type = 1, Width = 80, Height = 20 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0), + }; + // The button carries its own background media (authoredFaces stays + // empty — matches the real display buttons, which have their OWN + // frame art, not a lifted face-segment child). + info.StateMedia[""] = (0x06000001u, 1); + + var valueChild = new ElementInfo { Type = 12, X = 5, Y = 2, Width = 60, Height = 16 }; + info.Children.Add(valueChild); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == captionStringId ? "Attribute Credits" : null)); + + Assert.Equal("Attribute Credits", button.Label); + Assert.Null(button.ValueLabel); // nothing authored on the child itself + Assert.Equal((5f, 2f, 60f, 16f), button.ValueBox); + + // Writing the live value (as CharacterCreationProfessionPage.SetDisplay + // does) must not touch the caption — the whole point of this fix. + button.ValueLabel = "42"; + Assert.Equal("Attribute Credits", button.Label); + Assert.Equal("42", button.ValueLabel); + } + + /// + /// Negative companion: a button whose caption was LIFTED from a + /// distinct Type-12 child (the town-marker shape, + /// !ReferenceEquals(labelInfo, info)) must NOT pick up a + /// ValueBox even if the button happens to have another Type-12 child — + /// the gate is ReferenceEquals(labelInfo, info), own-caption + /// only. + /// + [Fact] + public void BuildButton_LiftedCaption_NeverSurfacesValueSlot() + { + uint stringId = 222u; + var info = new ElementInfo { Type = 1, Width = 106, Height = 80 }; + info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" }; + info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" }; + + var caption = new ElementInfo { Type = 12, X = 0, Y = 4, Width = 100, Height = 37 }; + caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0), + }; + info.Children.Add(caption); + + var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 }; + marker.StateMedia["Normal"] = (0x06004D60u, 1); + marker.StateMedia["Highlight"] = (0x06004D61u, 1); + info.Children.Add(marker); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == stringId ? "Holtburg" : null)); + + Assert.Equal("Holtburg", button.Label); + Assert.Null(button.ValueBox); + Assert.Null(button.ValueLabel); + } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── [Fact] From 5190e1691591b34d2c3e2038f381f82b948342b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 12:27:03 +0200 Subject: [PATCH 118/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20C=20=E2=80=94=20un-consume=20media-bearing?= =?UTF-8?q?=20children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 2/3: CLIENT-WIDE blast radius — un-consume media-bearing dat children on UiText/UiField. UiText.ConsumesDatChildren (true unless a state authors PassToChildren) and UiField.ConsumesDatChildren (true, unconditional) used to drop EVERY dat child at import time, including ones that carry their own renderable media — retail's UIElement_Text/Field genuinely composites those as real chrome/controls (frame pieces, linked scrollbars), not swallowed caption/face art the way a Button's or Meter's children are. LayoutImporter.BuildWidget gains a new carve-out (mirroring the existing UiMeter one): when a UiText/UiField's ConsumesDatChildren is true, build any child whose OWN StateMedia is non-empty (it carries a real sprite/track) instead of dropping it outright. Purely structural/ property-only children (StateMedia.Count == 0) stay dropped exactly as before — this is additive, not a relaxation of the PassToChildren gate. Independently re-derived blast-radius sweep (walks every installed LayoutDesc via DatCollection.GetAllIdsOfType, new LayoutImporterMediaBearingChildSweepTests): 37 distinct (layout, element) pairs — 41 raw tree positions, since a handful of element ids recur at multiple subtree positions within the same layout — across 15 layouts. Full list: 0x21000005/0x10000011 (x5 tree positions — chat-adjacent template reused across the layout), 0x21000005/0x1000059A [MAIN GAME UI], 0x21000006/0x10000011, 0x2100000F/0x1000059A, 0x21000038/{0x100003AB,0x100003BA,0x100003C4,0x100003E0,0x100003EC, 0x100003F6,0x100003FA,0x100003FD,0x100003FF,0x10000402,0x10000404, 0x10000405,0x10000409} [character creation], 0x21000043/0x10000362, 0x21000046/0x100003C4, 0x21000047/{0x100003E0,0x100003EC}, 0x21000048/{0x100003F6,0x100003FA,0x100003FD}, 0x21000049/{0x100003AB,0x100003BA}, 0x2100004A/0x10000409, 0x2100004B/{0x100003FF,0x10000402,0x10000404,0x10000405}, 0x2100004C/{0x100002DD,0x100002E5,0x100002E6}, 0x2100005B/0x10000011, 0x21000068/0x1000059A, 0x2100006F/0x10000011 [CHAT INPUT]. (This is an independent re-derivation, not a re-statement of the investigation's earlier "42/14" estimate — the small difference is expected from measuring with this commit's own criteria.) New tests: the sweep itself (pins the two flagged landmarks — MAIN GAME UI 0x21000005/0x1000059A and CHAT INPUT 0x2100006F/ 0x10000011 — plus the three chargen boxes), a build-through regression test confirming those two landmarks' children resolve as real widgets post-fix, and a chargen-scoped test confirming the eight gold-frame pieces + linked scrollbar on all three description boxes now resolve via UiElement.FindDescendant. Full App suite (Debug and Release, live-DAT): 5304 passed / 0 failed / 3 skipped — ZERO regressions across the whole client, including every existing chat and main-UI test. Runtime suite: 1735/0, unaffected (this is an App-layer-only change). FLAG FOR THE LEAD: automated coverage cannot catch a purely VISUAL regression (a frame drawing in the wrong place, a scrollbar overlapping text). Chat and the main game UI both got new dat children rendered for the first time this commit — schedule the user's own visual check of both before considering this closed, per the campaign's oracle discipline. The scrollbar linkage (wiring the description boxes' UiScrollbar to actual text scrolling) is NOT done in this commit — the scrollbar widget now BUILDS, but CharacterCreationHeritagePage/TownPage/ ProfessionPage/SummaryPage do not yet bind its ScalarChanged to UiText.Scroll. Filed as follow-up (see report). Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 28 ++++ .../Layout/CharacterCreationLiveDatTests.cs | 32 ++++ ...youtImporterMediaBearingChildSweepTests.cs | 151 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 06d78aa9..215f3093 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -164,6 +164,34 @@ public static class LayoutImporter if (cw is not null) w.AddChild(cw); } } + else if (w is UiText or UiField) + { + // Campaign CC gate round 1 Batch C, Commit 2: UiText/UiField's + // coarse ConsumesDatChildren=true (UiText outside its + // PassToChildren carve-out; UiField unconditionally) used to + // drop EVERY dat child, including ones that carry their own + // renderable media — retail's UIElement_Text/Field genuinely + // composites those as real chrome/controls, not swallowed + // caption/face art the way a Button's or Meter's children are. + // Live-DAT-measured (chargen's three shared description boxes, + // 0x100003e0/0x10000409/0x10000404): the eight gold-frame + // pieces (0x100002DE-E3, 0x100000E8/EA, Type 3, one DirectState + // sprite each) and the linked scrollbar (0x100002E7, Type 11, + // its own DirectState track sprite plus three Button + // sub-children BuildScrollbar resolves internally) all carry + // non-empty StateMedia on THEMSELVES. Purely structural/ + // property-only children (StateMedia.Count == 0 — e.g. a + // lifted-caption-only child some OTHER element type might + // still want swallowed) stay dropped exactly as before; this + // is additive, not a relaxation of the PassToChildren gate + // itself. + foreach (var child in info.Children) + { + if (child.StateMedia.Count == 0) continue; + var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId); + if (cw is not null) w.AddChild(cw); + } + } // UIElement::SetState @ 0x00464E70 propagates a state's id only after the // child tree exists. Re-applying the imported default here gives retained diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index a9664b8a..e8fc405e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1207,6 +1207,38 @@ public sealed class CharacterCreationLiveDatTests Assert.Contains(summaryHowTo.Children, c => c.Id == 0x100002E7u); } + /// + /// Campaign CC gate round 1 Batch C, Commit 2: the eight gold-frame + /// pieces and the linked scrollbar — previously dropped outright by + /// UiText.ConsumesDatChildren — now build as REAL widgets + /// reachable via , on all three + /// chargen description boxes. + /// + [InstalledDatFact] + public void DescriptionTextboxes_FramesAndScrollbarBuildAsRealWidgets() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + uint[] frameChildIds = + [ + 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, + 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, + ]; + foreach (uint boxId in new[] { 0x100003E0u, 0x10000409u, 0x10000404u }) + { + UiText box = Assert.IsType(screen.FindElement(boxId)); + foreach (uint frameChildId in frameChildIds) + Assert.NotNull(UiElement.FindDescendant(box, frameChildId)); + } + + UiText summaryHowTo = Assert.IsType(screen.FindElement(0x10000404u)); + Assert.IsType(UiElement.FindDescendant(summaryHowTo, 0x100002E7u)); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs new file mode 100644 index 00000000..97be363d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs @@ -0,0 +1,151 @@ +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC gate round 1 Batch C, Commit 2: client-wide blast-radius +/// sweep for the UiText/UiField media-bearing-child +/// un-consume fix ('s new UiText or +/// UiField carve-out). Walks EVERY installed LayoutDesc +/// (DatCollection.GetAllIdsOfType<LayoutDesc>) and reports +/// every Type-12 (UIElement_Text) element that does NOT author +/// PassToChildren on any state (the pre-fix "consumes everything" shape) +/// but HAS at least one direct child carrying its own state media — the +/// exact set the fix now builds instead of silently dropping. Logged via +/// Console.WriteLine so the full enumeration is visible in test +/// output for the commit message; the assertions pin only landmark counts/ +/// elements (not a brittle exact global total) so the gate survives a +/// future DAT revision without going red on an unrelated content change. +/// +public sealed class LayoutImporterMediaBearingChildSweepTests +{ + private static string DatDirectory => + System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + + private readonly record struct Finding(uint LayoutId, uint ElementId, uint[] MediaBearingChildIds); + + [InstalledDatFact] + public void MediaBearingChildSweep_EnumeratesEveryAffectedType12Element() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + var findings = new List(); + foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(x => x)) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + if (tree is null) continue; + Walk(layoutId, tree, findings); + } + + Console.WriteLine($"[SWEEP] {findings.Count} affected Type-12 elements across " + + $"{findings.Select(f => f.LayoutId).Distinct().Count()} layouts."); + foreach (Finding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId)) + { + Console.WriteLine( + $"[SWEEP] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8} " + + $"mediaBearingChildren=[{string.Join(",", f.MediaBearingChildIds.Select(id => $"0x{id:X8}"))}]"); + } + + // Landmarks the investigation specifically flagged for the user's + // visual check — assert they are genuinely in the affected set + // (not asserting a brittle exact global count). + Assert.Contains(findings, f => f.LayoutId == 0x21000005u && f.ElementId == 0x1000059Au); + Assert.Contains(findings, f => f.LayoutId == 0x2100006Fu && f.ElementId == 0x10000011u); + + // The three chargen description boxes this campaign already fixed. + Assert.Contains(findings, f => f.ElementId == 0x100003E0u); // Profession + Assert.Contains(findings, f => f.ElementId == 0x10000409u); // Town + Assert.Contains(findings, f => f.ElementId == 0x10000404u); // Summary how-to + + Assert.True(findings.Count > 0, "the sweep must find at least the known chargen landmarks."); + } + + /// + /// Regression pin (Commit 2): the two landmarks the investigation + /// flagged for the user's own visual check actually BUILD their + /// media-bearing children as real widgets now, on a NON-chargen + /// layout — proving the fix is not accidentally chargen-only. FLAG: + /// this is a structural/widget-level pin only; the user's own visual + /// check of chat + the main game UI is still owed (the lead schedules + /// it) — a passing test here does not stand in for that. + /// + [InstalledDatFact] + public void MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + // MAIN GAME UI (0x21000005/0x1000059A): the same eight gold-frame + // pieces the chargen boxes carry. + AssertChildrenBuild( + dats, layoutId: 0x21000005u, elementId: 0x1000059Au, + expectedChildIds: + [ + 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, + 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, + ]); + + // CHAT INPUT (0x2100006F/0x10000011): a single media-bearing child. + AssertChildrenBuild( + dats, layoutId: 0x2100006Fu, elementId: 0x10000011u, + expectedChildIds: [0x1000048Cu]); + } + + private static void AssertChildrenBuild( + IDatReaderWriter dats, uint layoutId, uint elementId, uint[] expectedChildIds) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + Assert.NotNull(tree); + ElementInfo? target = FindInfo(tree!, elementId); + Assert.NotNull(target); + + UiElement built = LayoutImporter.Build( + target!, _ => (0u, 0, 0), null).Root; + Assert.IsType(built); + foreach (uint childId in expectedChildIds) + { + Assert.NotNull(UiElement.FindDescendant(built, childId)); + } + } + + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (ElementInfo child in node.Children) + { + ElementInfo? found = FindInfo(child, id); + if (found is not null) return found; + } + return null; + } + + private static void Walk(uint layoutId, ElementInfo node, List findings) + { + if (node.Type == 12u) + { + bool passToChildren = node.States.Values.Any(static s => s.PassToChildren); + if (!passToChildren) + { + uint[] mediaBearingChildren = node.Children + .Where(static c => c.StateMedia.Count > 0) + .Select(static c => c.Id) + .ToArray(); + if (mediaBearingChildren.Length > 0) + findings.Add(new Finding(layoutId, node.Id, mediaBearingChildren)); + } + } + + foreach (ElementInfo child in node.Children) + Walk(layoutId, child, findings); + } +} From 2349f8b4dfa68312a107c86f0be1188216f5b707 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 12:34:38 +0200 Subject: [PATCH 119/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20C=20=E2=80=94=20Summary=20how-to=20+=20scro?= =?UTF-8?q?llbar=20linkage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 3/3: Summary how-to text + Commit 2's owed scrollbar linkage + bookkeeping sweep. - Ports gmCGSummaryPage::SetHowToText @0x0047ae20 into the Summary page's how-to box (0x10000404, HowToTextId was declared and unused since CC5). Retail concatenates ID_CharGen_SummaryHowTo + a heritage/ gender-specific name-suggestion list (heritages 1-4 — Aluvian/ Gharundim/Sho/Viamontian — only; heritages 5-13's cases in the same switch decompile to a vtable-slot artifact, the same decompiler- mangled-symbol class the Heritage page's own BonusSkillsKeyByHeritage table already documents, so no name-suggestion string exists for them and none is invented) + ID_CharGen_SummaryHowToEnd, directly concatenated (no separator literal) into ONE plain SetText call — no per-run font/color argument, unlike Heritage's ...WithFont calls, so this routes through DatRichText as a single DefaultColor segment. - Wires the description boxes' linked scrollbar to actual text scrolling — Commit 2 made the scrollbar child (0x100002e7) BUILD as a real UiScrollbar; this binds scrollbar.Model = text.Scroll, the exact pattern ChatWindowController already uses for the chat transcript. Live-DAT-measured: only Heritage's description (0x100003c4) and Summary's how-to box (0x10000404) actually author this child — Profession/Town's shorter description boxes do not (a genuine retail authoring fact, not something to "fix" further). Register: AP-215/AP-216/AP-217 rewritten (Batch C's Commit 1 already retired AP-218/AD-103) — no further changes needed this commit; ISSUES #366 (chat's new-unseen-text indicator, 0x1000048C under the chat transcript 0x10000011) NARROWED — its own pre-filed "fix shape" recommendation (a UiText child carve-out mirroring UiMeter's) is EXACTLY what Commit 2 shipped, confirmed by that commit's own client-wide sweep; #366 stays open for the still-missing behavioral half (no controller drives the indicator's visibility/click). Findings doc updated: GF-2/GF-3/GF-4/GF-6/GF-11a/GF-12/GF-14's text half all marked FIXED with their own root-cause notes; the two remaining "suspected shared roots" (frames/labels, rich text) marked CONFIRMED + CLOSED. Full App suite (Debug and Release, live-DAT): 5307 passed / 0 failed / 3 skipped (up from 5304 after Commit 2). Runtime suite: 1735/0, unaffected. Campaign CC gate round 1 Batch C is CODE-COMPLETE across all three commits — GF-2, GF-3, GF-4, GF-6, GF-11a, GF-12, and GF-14's text half are fixed; AP-216/AP-217 partially closed (register-honest about what shipped vs what needs a palette-to-RGB pipeline this batch didn't add). Pending the user's visual gate, with chat + the main game UI flagged for extra attention (Commit 2's client-wide blast radius). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 40 +++-- ...-08-16-campaign-cc-gate-round1-findings.md | 151 +++++++++++++++--- .../Layout/CharacterCreationHeritagePage.cs | 13 ++ .../UI/Layout/CharacterCreationSummaryPage.cs | 95 +++++++++++ .../Layout/CharacterCreationLiveDatTests.cs | 76 +++++++++ .../CharacterCreationUiControllerTests.cs | 61 +++++++ 6 files changed, 391 insertions(+), 45 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 307ebde0..c405ab0e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1791,29 +1791,25 @@ controllers read instead of the main window's private field. ## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired -**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main -chat window authors a 16×16 "new unseen text" indicator button -(`0x1000048C`, base `0x10000527`/`0x21000040`) as a CHILD of the transcript -text element `0x10000011` (position `(0,57)` relative to the transcript, -i.e. bottom-left of the transcript pane), confirmed in the `0x2100006F` -LayoutDesc dump. `UiText.ConsumesDatChildren` is `true` (Type-12 behavioral -widgets reproduce their dat sub-elements procedurally per -`DatWidgetFactory`'s own doc comment), so `LayoutImporter.BuildWidget` -never builds `0x1000048C` as a separate widget — it is silently swallowed, -same as under the wrong `0x21000006` layout before it (not a CH6a -regression). No controller anywhere binds or drives its visible state. -**Not in CH6a's scope** (transcript/input/scrollbar/1-4-buttons only) and -not obviously CH6b/CH6c's either — files here as a standalone gap. Fix -shape: either give `UiText` an opt-in mechanism to keep specific named -non-Type-3 children (mirroring `UiMeter`'s existing text-overlay carve-out -in `DatWidgetFactory.BuildWidget`), or handle `0x1000048C` as a special -case the same way. Needs research first: what triggers retail's "new text" -indicator (unread-since-scroll-position?) and what it visually does on -click — not decoded by CH6a. +**Status:** OPEN, NARROWED 2026-08-16 at Campaign CC gate round 1 Batch C +Commit 2 — the BUILD half of this issue's own "fix shape" recommendation is +now DONE. `LayoutImporter.BuildWidget` gained a `UiText`/`UiField` +media-bearing-child carve-out (mirroring `UiMeter`'s own text-overlay +carve-out, EXACTLY the shape this issue proposed) as part of a chargen +description-box fix; the client-wide blast-radius sweep that fix's own +tests run +(`LayoutImporterMediaBearingChildSweepTests.MediaBearingChildSweep_EnumeratesEveryAffectedType12Element`) +independently re-confirmed `0x1000048C` under `0x10000011` in layout +`0x2100006F` as one of the affected elements — it now builds as a real +widget instead of being silently swallowed. **Still open:** no controller +binds or drives its visible state (STILL the original ask — what triggers +retail's "new text" indicator, and what it does on click, remains +un-researched); this issue stays open for that behavioral half. -**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs`; -`src/AcDream.App/UI/Layout/LayoutImporter.cs` -(`BuildWidget`/`ConsumesDatChildren` handling); `src/AcDream.App/UI/UiText.cs`. +**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs` (behavior, +still missing); `src/AcDream.App/UI/Layout/LayoutImporter.cs` +(`BuildWidget`'s new `UiText or UiField` carve-out — CLOSED the build half); +`src/AcDream.App/UI/UiText.cs`. ## #367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 73f0bd66..ddd591b8 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -15,6 +15,32 @@ Fixture + live-DAT tests only this round (no graphical client launch); App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Register: AP-222 RETIRED, AP-215 NARROWED (item 1 retired, item 2 stays open). +**Batch C (text/frame/label fidelity, the largest visual batch) is +CODE-COMPLETE 2026-08-16, pending the user's visual gate.** GF-2, GF-3, +GF-4, GF-6, GF-11a, GF-12, and GF-14's text half are fixed — see each +entry's own FIXED note below. Three commits: (1) chargen-scoped rich +text + labels + backdrops (new shared `DatRichText` composer; `UiButton` +gains a coexisting `ValueLabel` slot; Heritage/Profession backdrop +`SetState` cascades; AP-216/AP-217 partially closed — the "beyond count"/ +"Eyes-blank" halves ship, the palette-to-RGB "actual color"/"gradient +tint" halves stay open, judged disproportionate to add alongside this +batch's ~10 other fixes), (2) a CLIENT-WIDE `LayoutImporter` fix +un-consuming media-bearing dat children on `UiText`/`UiField` (37 distinct +(layout, element) pairs across 15 layouts, independently re-derived — +includes MAIN GAME UI and CHAT INPUT, closing the build half of pre-filed +issue #366), (3) the Summary how-to text +(`gmCGSummaryPage::SetHowToText`) plus the scrollbar-to-text-scroll +linkage Commit 2 left unbound. Fixture + live-DAT tests only (no +graphical client launch); App suite 5307/3 (was 5282/3, +25 tests, one +pre-existing full-suite-only allocation flake unrelated to this batch — +passes in isolation and in the full Release run), Runtime 1735/0 +unchanged. Register: AP-215/AP-216/AP-217 rewritten, AP-218 retired, +AD-103 retired. **FLAG FOR THE LEAD: chat and the main game UI both +render new dat children (gold frames, an unseen-text indicator) for the +first time this batch — the user's own visual check of both is owed +before considering Commit 2 closed; automated coverage cannot catch a +purely visual placement regression.** + User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, windowed). Screenshots: retail Heritage, acdream Heritage, retail Profession. The user's side-by-side retail reports are AXIOMS @@ -86,7 +112,17 @@ ISSUES.md; this doc is the six-page batch. exactly one overlay visible per part, tracking the current part's selected color index; retires AP-215's swatch-selection substitution (item 1 — the icon-vs-ordinal item 2 stays open). -- **GF-11a Town description text does not change** when switching towns. +- **GF-11a Town description text does not change when switching towns — + FIXED (Batch C, Commit 1).** The composed string + (`gmCGTownPage::SetTownString @0x0047c1f0`'s `howTo + "\n\n" + townText + + "\n"` — this ONE composition site is where retail's OWN code, not the + authored DAT string content, inserts the separator, confirmed via the + compiled format literal's raw bytes `u"\n\n%s\n"`) was already + byte-correct; the real gap was rendering it as a single un-wrapped line, + so the town-specific SUFFIX rendered past the clipped viewport — + switching towns changed the underlying string but not what was visibly + on screen. Fixed by routing through `DatRichText.Compose` (same fix + family as GF-2). - **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED (Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat property `0x3B` (Invisible — `UIElement::OnSetAttribute @0x00462d80` @@ -151,20 +187,55 @@ ISSUES.md; this doc is the six-page batch. ## Presentation families (retail parity) -- **GF-2 Description textboxes broken everywhere.** acdream renders the - raw string with LITERAL `\n` escapes, one truncated line, no wrap, no - scroll, no frame. Retail: framed scrollable textbox, multi-paragraph, - colored section headers (green "Trained Starting Skills:" etc.), - scrollbar + arrows (screenshot 1 right panel). -- **GF-3 Profession template description textbox missing** (retail bottom- - left panel, "LIFE CASTERS are experts…" — screenshot 3). -- **GF-4 Profession labels missing:** "Attribute Credits" caption + value, - per-attribute name labels (Strength…Self), Health/Stamina/Mana labels + - values. Sliders and template selection themselves WORK. -- **GF-6 Appearance spin captions are numbers,** not part names - ("Hair Style", "Eyes", …). Known rows AP-215 (item 2 — item 1, the - swatch-selection substitution, RETIRED at Batch B/GF-9)/AP-218 — the - gate promotes them to must-port. +- **GF-2 Description textboxes broken everywhere — FIXED (Campaign CC gate + round 1, Batch C, Commit 1 + Commit 2).** Root cause was TWO stacked + gaps, both closed: (1) the Heritage/Town/Profession description pages + bypassed escape-normalize + word-wrap entirely, assigning a raw + unwrapped single-`Line` `LinesProvider` — fixed by routing every + description box through the new shared `DatRichText.Compose` helper + (ports `UIElement_Text::SetStringInfoWithFont`/`AppendStringInfoWithFont`'s + composition model: escape-normalize, per-segment word-wrap, per-segment + palette color — Heritage's own header/body segments now render in + retail's own green/white, matching `AppendStringInfoWithFont`'s font-index + argument). (2) The authored gold frame (8 pieces) and linked scrollbar + were silently dropped by `UiText.ConsumesDatChildren` — fixed by + Commit 2's client-wide `LayoutImporter` carve-out (see GF-12). Both + halves are pinned by live-DAT tests (`CharacterCreationLiveDatTests`, + `LayoutImporterMediaBearingChildSweepTests`) and unit tests + (`DatRichTextTests`). +- **GF-3 Profession template description textbox missing — FIXED (Batch C, + Commit 1).** `gmCGProfessionPage::InitializePage @0x00483068`'s + `m_pTextBox` (`0x100003e0`) was never bound. Fixed: + `CharacterCreationProfessionPage` now binds it and composes the + per-template string (`ID_CharGen_CustomText`/`BowText`/`SwashText`/ + `LifeText`/`WarText`/`WayText`/`SoldierText`, `UpdateProfession + @0x004821b0`'s per-case literal, plain `SetStringInfo` — one color, no + palette) through the same `DatRichText` helper. +- **GF-4 Profession labels missing — FIXED (Batch C, Commit 1).** Two + distinct mechanisms, both closed: (a) the four display buttons + (avail/health/stamina/mana credits) author their caption directly as + their own P0x17 AND carry a separate media-less Type-12 value child that + `UiButton.ConsumesDatChildren` used to drop entirely — pages substituted + the button's own `.Label`, destroying the caption. Fixed by giving + `UiButton` a coexisting `ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` + slot (`DatWidgetFactory.BuildButton`, gated on the button's own P0x17 + caption existing), so the caption and the live value now render + independently — the same fix also closes the Skills page's credits + badge. (b) The six slider name labels (`0x100002ed`, live-DAT-measured + as `UIElement_Button` — retail's `UIElement_Button` is DynamicCast(0xc)- + compatible with `UIElement_Text`) are now written once at construction + with `CharGenState::GetAttributeName @0x005C3A20`'s six hardcoded + literals, matching retail's own single `InitializePage`-time write + (never re-written on refresh, same as retail). +- **GF-6 Appearance spin captions are numbers — FIXED (Batch C, Commit 1), + retiring AP-218.** `gmCGAppearancePage::Update @0x0047e8f0` writes a + heritage-flavored STATIC caption to the Hair/Eyes/Skin spins only + (normal / `GearText_*` / `OlthoiText_*` variants) — never an index, and + never touches the other six spins' own DAT-authored caption at all. + Removed the prior 1-based-ordinal/gear-name substitution outright. + AP-215's own icon-thumbnail item (the four icon-only spins still show no + per-choice icon art — a DIFFERENT, still-open gap) is rewritten, not + retired — see that row. - **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14); retail's chargen 3D view shows a scenic backdrop. (The Heritage-page preview area shows terrain in BOTH clients — establish from the decomp @@ -218,19 +289,53 @@ ISSUES.md; this doc is the six-page batch. caption carries its own rect, the label draws within THAT box using its own authored justify instead of the face-relative offset; every other button (`LabelBox` null) keeps the EXACT prior draw math. -- **GF-12 Missing authored gold frames** around boxes on every page - (Skills/Appearance/Town/Summary called out explicitly). -- **GF-14 Summary paperdoll backdrop black** (same family as GF-7); - Summary textbox wrapper + scrollbar missing (GF-2 family). +- **GF-12 Missing authored gold frames around boxes — FIXED (Batch C, + Commit 2).** Root cause: `UiText.ConsumesDatChildren` (true unless a + state authors PassToChildren) and `UiField.ConsumesDatChildren` + (unconditionally true) dropped EVERY dat child at import time, + including the eight gold-frame pieces (`0x100002DE..E3`, + `0x100000E8/EA`) every description/report box authors. Fixed by a new + `LayoutImporter.BuildWidget` carve-out (mirroring the existing `UiMeter` + text-overlay carve-out): build any child with its OWN non-empty + `StateMedia`, leaving purely structural/property-only children dropped + as before. Independently re-derived blast radius: **37 distinct + (layout, element) pairs across 15 layouts** (see the commit message for + the full enumeration), including MAIN GAME UI (`0x21000005/0x1000059A`) + and CHAT INPUT (`0x2100006F/0x10000011` — closing the BUILD half of + pre-filed issue #366's own "fix shape" recommendation, which proposed + this EXACT carve-out). Full App suite (5304 tests): zero regressions. + **The user's own visual check of chat + the main game UI is still owed** + — automated coverage cannot catch a purely visual placement regression. +- **GF-14 Summary paperdoll backdrop black** (same family as GF-7, + UNCHANGED, out of this batch's scope — the 3-D preview backdrop, not a + text-widget gap). **Summary textbox wrapper + scrollbar — FIXED (Batch + C, Commit 2 for the frame/build half, Commit 3 for the scrollbar LINK + and the how-to text's own content — see the Suspected-shared-roots + entry and Commit 3's own composition of `gmCGSummaryPage::SetHowToText` + into `0x10000404`).** - **GF-16 Hover tooltips missing on all pages** (retail pops tooltips). ## Suspected shared roots (to be CONFIRMED by the investigation, not assumed) -1. Missing frames/labels/statics across every page (GF-3, GF-4 labels, +1. ~~Missing frames/labels/statics across every page (GF-3, GF-4 labels, GF-12) — one importer/mount-level gap OR retail writes them at runtime; - decide per element from the authored DAT + decomp. -2. Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap - feeding GF-2/GF-3/GF-11a/GF-14. + decide per element from the authored DAT + decomp.~~ CONFIRMED, CLOSED + at Batch C: TWO distinct mechanisms, both a single shared fix each. (a) + GF-3/GF-4's labels were runtime-composition gaps (unbound description + textbox; a value-write clobbering a caption) — fixed per-page, Commit 1. + (b) GF-12's frames were the importer-level gap the investigation + suspected: `LayoutImporter.BuildWidget`'s `ConsumesDatChildren` handling + dropped every dat child of a `UiText`/`UiField`, client-wide — fixed by + the new media-bearing-child carve-out, Commit 2. +2. ~~Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap + feeding GF-2/GF-3/GF-11a/GF-14.~~ CONFIRMED, CLOSED at Batch C: the new + shared `DatRichText.Compose` helper (escape-normalize + per-segment + word-wrap + per-segment palette color, Commit 1) plus the Commit-2 frame/ + scrollbar un-consume plus Commit-3's scrollbar-to-text-scroll linkage + (`UiScrollbar.Model = text.Scroll`, the same pattern + `ChatWindowController` already used) together close the WHOLE family — + GF-2/GF-3/GF-11a/GF-14's text half are all FIXED; GF-14's backdrop half + (GF-7 family) is unrelated and stays open. 3. ~~Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, GF-10 zoom art) — the AP-222 measured mechanism (state media authored vs applied) across widget kinds.~~ CLOSED, split into TWO distinct diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index 7a79b465..0a2a03cb 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -135,6 +135,19 @@ internal sealed class CharacterCreationHeritagePage : IDisposable _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; _backdrop = UiElement.FindDescendant(pageRoot, 0x100003BEu); + + // Commit 2/3 follow-up (Campaign CC gate round 1 Batch C): the + // description box's own linked scrollbar — live-DAT-measured + // present here (unlike Profession/Town's shorter description + // boxes, which author no scrollbar child at all) at the SAME + // relative id CharacterCreationSummaryPage's how-to box carries. + // ChatWindowController's own scrollbar.Model = transcript.Scroll + // pattern, scoped to this box's own descendant. + if (_description is not null + && UiElement.FindDescendant(_description, 0x100002E7u) is UiScrollbar descriptionScroll) + { + descriptionScroll.Model = _description.Scroll; + } } internal void Refresh( diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs index 94545683..c90a3943 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -76,11 +76,48 @@ internal sealed class CharacterCreationSummaryPage : IDisposable /// private const int MaxNameLength = 32; + /// + /// Commit 3 (Campaign CC gate round 1 Batch C): the how-to box's own + /// linked scrollbar, relative id — live-DAT-measured as the SAME + /// template child id the Heritage description box also carries + /// (0x100002e7), distinct from (the + /// listbox's own scrollbar). Profession/Town's description boxes do + /// NOT author this child at all (shorter authored text, live-DAT- + /// confirmed) — only Heritage and Summary's how-to box do. + /// + private const uint HowToScrollRelativeId = 0x100002E7u; + + /// + /// Heritage id -> (male name-list key, female name-list key) per + /// gmCGSummaryPage::SetHowToText @0x0047ae20's switch + /// (@0x0047aeb2-0x0047afda). ONLY heritages 1-4 (Aluvian/Gharundim/ + /// Sho/Viamontian) resolve to real string literals + /// ("ID_CharGen_<Abbrev>{Male,Female}Names", confirmed + /// present in the compiled string-constant table); every other + /// heritage's case in that same switch (5/0xa Shadowbound+Penumbraen + /// share one body, 6 Gearknight, 7 Tumerok, 8 Lugian, 9 Empyrean, 0xb + /// Undead, 0xc/0xd Olthoi/OlthoiAcid) decompiles to a vtable-slot + /// artifact instead of a string constant — the same decompiler- + /// mangled-symbol class the Heritage page's own + /// BonusSkillsKeyByHeritage table already documents — meaning + /// no real name-suggestion string exists for those heritages; this + /// port does not invent one. + /// + private static readonly IReadOnlyDictionary NameSuggestionKeysByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = ("ID_CharGen_AluMaleNames", "ID_CharGen_AluFemaleNames"), + [(uint)ChargenHeritageGroup.Gharundim] = ("ID_CharGen_GharuMaleNames", "ID_CharGen_GharuFemaleNames"), + [(uint)ChargenHeritageGroup.Sho] = ("ID_CharGen_ShoMaleNames", "ID_CharGen_ShoFemaleNames"), + [(uint)ChargenHeritageGroup.Viamontian] = ("ID_CharGen_ViaMaleNames", "ID_CharGen_ViaFemaleNames"), + }; + private readonly CharacterCreationRuntimeBindings _bindings; private readonly RetailDialogFactory _dialogs; private readonly string _nameTooLongMessage; private readonly UiTemplateListBox? _list; private readonly UiField? _nameField; + private readonly UiText? _howToText; private string _lastCommittedName = string.Empty; private uint _nameTooLongDialogContext; private bool _disposed; @@ -145,6 +182,19 @@ internal sealed class CharacterCreationSummaryPage : IDisposable } Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport; + + // Commit 2/3 follow-up: the how-to box's linked scrollbar (Commit + // 2 made it BUILD as a real UiScrollbar; this wires it to actual + // scrolling) — ChatWindowController's own scrollbar.Model = + // transcript.Scroll pattern, scoped to THIS box's own descendant + // (the relative id recurs on Heritage's description box too, so a + // flat screen-wide lookup would be ambiguous). + _howToText = UiElement.FindDescendant(pageRoot, HowToTextId) as UiText; + if (_howToText is not null + && UiElement.FindDescendant(_howToText, HowToScrollRelativeId) is UiScrollbar howToScroll) + { + howToScroll.Model = _howToText.Scroll; + } } internal void Refresh( @@ -176,6 +226,51 @@ internal sealed class CharacterCreationSummaryPage : IDisposable RebuildListbox(view, snapshot); RebuildPreview(view, snapshot); + RefreshHowToText(snapshot); + } + + /// + /// Commit 3 (Campaign CC gate round 1 Batch C): ports + /// gmCGSummaryPage::SetHowToText @0x0047ae20. Retail + /// concatenates ID_CharGen_SummaryHowTo + + /// (heritage/gender-specific name-suggestion list, heritages 1-4 + /// only) + ID_CharGen_SummaryHowToEnd directly + /// (append_n_chars, no separator literal) into ONE plain + /// UIElement_Text::SetText — no per-run font/color argument, + /// unlike Heritage's ...WithFont calls, so this is a single + /// segment. + /// + private void RefreshHowToText(RuntimeCharacterCreationSnapshot snapshot) + { + if (_howToText is null) + return; + + Func? resolveText = _bindings.ResolveText; + if (resolveText is null) + return; + + var builder = new System.Text.StringBuilder(); + if (resolveText("ID_CharGen_SummaryHowTo") is { } howTo) + builder.Append(howTo); + if (NameSuggestionKeysByHeritage.TryGetValue(snapshot.HeritageId, out (string Male, string Female) keys)) + { + // gmCGSummaryPage::SetHowToText @0x0047af3f et al.: the raw + // "!= 2" comparison, no gender-unset special case — an unset + // gender (0) takes the male-key branch, matching retail's own + // literal comparison. + string key = snapshot.GenderKey == 2u ? keys.Female : keys.Male; + if (resolveText(key) is { } nameTokens) + builder.Append(nameTokens); + } + if (resolveText("ID_CharGen_SummaryHowToEnd") is { } howToEnd) + builder.Append(howToEnd); + + if (builder.Length == 0) + return; + + string composed = builder.ToString(); + var segments = new[] { new DatRichText.Segment(composed, _howToText.DefaultColor) }; + _howToText.LinesProvider = () => DatRichText.Compose(_howToText, segments); } // ── Name field (ListenToElementMessage @ 0x0047bf40) ──────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index e8fc405e..892439fe 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -542,6 +542,35 @@ public sealed class CharacterCreationLiveDatTests "ID_CharGen_ShoushiText", "ID_CharGen_YaraqText", "ID_CharGen_SanamarText", + // GF-3: Profession template description keys. + "ID_CharGen_CustomText", + "ID_CharGen_BowText", + "ID_CharGen_SwashText", + "ID_CharGen_LifeText", + "ID_CharGen_WarText", + "ID_CharGen_WayText", + "ID_CharGen_SoldierText", + // GF-6/AP-218: Appearance spin caption keys. + "ID_CharGen_HairStyle", + "ID_CharGen_Eyes", + "ID_CharGen_Skin", + "ID_CharGen_GearText_HairButton", + "ID_CharGen_GearText_EyesButton", + "ID_CharGen_GearText_SkinButton", + "ID_CharGen_OlthoiText_HairButton", + "ID_CharGen_OlthoiText_EyesButton", + "ID_CharGen_OlthoiText_SkinButton", + // Commit 3: Summary how-to text keys. + "ID_CharGen_SummaryHowTo", + "ID_CharGen_SummaryHowToEnd", + "ID_CharGen_AluMaleNames", + "ID_CharGen_AluFemaleNames", + "ID_CharGen_GharuMaleNames", + "ID_CharGen_GharuFemaleNames", + "ID_CharGen_ShoMaleNames", + "ID_CharGen_ShoFemaleNames", + "ID_CharGen_ViaMaleNames", + "ID_CharGen_ViaFemaleNames", ]; foreach (string key in keys) { @@ -1239,6 +1268,53 @@ public sealed class CharacterCreationLiveDatTests Assert.IsType(UiElement.FindDescendant(summaryHowTo, 0x100002E7u)); } + /// + /// Commit 3: the Heritage description box (which ALSO carries the + /// linked scrollbar — live-DAT-measured, unlike Profession/Town's + /// shorter description boxes) resolves it as a real widget too, and + /// the mount wires to the box's own + /// . + /// + [InstalledDatFact] + public void HeritageDescription_ScrollbarBuildsAndLinksToTextScroll() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement heritageRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + UiText description = Assert.IsType( + UiElement.FindDescendant(heritageRoot, 0x100003C4u)); + UiScrollbar scroll = Assert.IsType( + UiElement.FindDescendant(description, 0x100002E7u)); + + var host = new UiRoot(); + var dialogs = MakeDialogFactory(dats, host); + var bindings = new CharacterCreationRuntimeBindings( + () => null, + _ => default, _ => default, _ => default, (_, _) => default, (_, _) => default, + _ => default, _ => default, _ => default, _ => default, _ => default, () => { }); + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) => + LayoutImporter.Import( + dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root; + + CharacterCreationUiController? controller = + CharacterCreationUiController.CreateDetached( + host, screen, ResolveTemplate, dialogs, bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long")); + Assert.NotNull(controller); + controller!.AttachAndTick(); + + Assert.Same(description.Scroll, scroll.Model); + + controller.Dispose(); + dialogs.Dispose(); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 91b98caf..8790f9bb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1663,6 +1663,66 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(0x1000002Cu, professionBackdrop.ActiveRetailStateId); } + /// Commit 3: the Summary how-to text concatenates HowTo + + /// the heritage/gender name-suggestion list (heritages 1-4 only) + + /// HowToEnd, in that order, no separator inserted by code. + [Fact] + public void SummaryHowToText_ComposesHowToPlusNameSuggestionsPlusHowToEnd_ForNamedHeritages() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowTo"] = "HOWTO."; + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowToEnd"] = "HOWTOEND."; + environment.Runtime.ResolvedStrings["ID_CharGen_AluMaleNames"] = "Alucard, Aldric"; + environment.Runtime.ResolvedStrings["ID_CharGen_AluFemaleNames"] = "Alura, Aldyth"; + environment.Controller.Open(); + + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.Runtime.SelectGenderDirect(1u); // male + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + + UiText howTo = Assert.IsType( + environment.Screen.FindElement(CharacterCreationSummaryPage.HowToTextId)); + string composed = JoinedText(howTo); + Assert.Contains("HOWTO.", composed); + Assert.Contains("Alucard, Aldric", composed); + Assert.DoesNotContain("Alura, Aldyth", composed); + Assert.Contains("HOWTOEND.", composed); + // No code-inserted separator: HowTo's own tail must be immediately + // followed by the name-list segment's own head, character for + // character (only whichever whitespace the AUTHORED strings + // themselves carry — none, in this test's fixture strings). + Assert.Contains("HOWTO.Alucard, Aldric", composed.Replace("\n", string.Empty)); + + environment.Runtime.SelectGenderDirect(2u); // female + BumpRevisionAndTick(environment); + string femaleComposed = JoinedText(howTo); + Assert.Contains("Alura, Aldyth", femaleComposed); + Assert.DoesNotContain("Alucard, Aldric", femaleComposed); + } + + /// Heritages without a real retail name-suggestion string + /// (5-13, the decompiler-artifact cases) compose HowTo directly + /// against HowToEnd — no invented text, no crash. + [Fact] + public void SummaryHowToText_SkipsNameSuggestions_ForHeritagesWithNoRealString() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowTo"] = "HOWTO."; + environment.Runtime.ResolvedStrings["ID_CharGen_SummaryHowToEnd"] = "HOWTOEND."; + environment.Controller.Open(); + + environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Undead); + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + + UiText howTo = Assert.IsType( + environment.Screen.FindElement(CharacterCreationSummaryPage.HowToTextId)); + string composed = JoinedText(howTo); + Assert.Contains("HOWTO.", composed); + Assert.Contains("HOWTOEND.", composed); + } + private static void BumpRevisionAndTick(EnvironmentHarness environment) { RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; @@ -1897,6 +1957,7 @@ public sealed class CharacterCreationUiControllerTests public Dictionary ResolvedStrings { get; } = []; public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); + public void SelectGenderDirect(uint genderKey) => SelectGender(genderKey); public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => View.GetSkillLevel(skillId); From 63bf64c93459a7701f9d900e7694f6c65b042836 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 13:05:23 +0200 Subject: [PATCH 120/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20D=20=E2=80=94=20gmCG3DView=20environment=20?= =?UTF-8?q?backdrop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail's chargen 3D views (Appearance and Summary) are not black behind the model: gmCG3DView::Update @0x004EE9D0 constructs a SECOND CPhysicsObj from the current heritage's HeritageGroup_CG.environmentSetupID field (acclient.h verbatim struct layout; the decompiler elides the actual field read, but HeritageGroup_CG::GetSubDataIDs @0x005c05d0 explicitly walks iconImage/setupID/environmentSetupID by name, confirming the identity) and adds it to the SAME viewport's creature_mode_objects the player object lives in, inserted BEFORE the player (whose own re-AddObject happens much later, at ~0x004ef199, after the full clothing ObjDesc composes). The backdrop gets no explicit position/orientation/scale — CPhysicsObj:: makeObject(eax_32, 0, 1) leaves it at the scene origin with identity orientation, same as the player object's own placement. This id was already parsed as ChargenHeritageOptions.EnvironmentSetupId (ChargenTableReader.cs) but never consumed anywhere in production (GF-7/ GF-14). Fixed by: - ChargenPreviewEntityBuilder.TryBuildBackdrop: builds a plain, unposed Setup mesh from the heritage's EnvironmentSetupId, returning null for id 0/unset or an unresolvable Setup (retail's own INVALID_DID gate). - PrivateEntityViewportRenderer: an optional second entity slot (SetBackdrop), reserved via a backdropRenderId constructor parameter so paperdoll and creature-appraisal — which never pass one — cannot acquire a second entity even by accident (SetBackdrop throws without a reserved slot). Per-entity mesh-reference/texture-owner lifetime is factored into a private EntitySlot helper shared by both the main and backdrop slots. Draw-entity assembly is a pure, directly-testable helper (BuildDrawEntities) that puts the backdrop first, matching retail's own AddObject insertion order. - ChargenPreviewController.Rebuild: rebuilds the backdrop whenever the HERITAGE changes (narrower than the existing camera-eye-reset gate, since environmentSetupID is a pure function of heritage, never gender or appearance selection). Both Appearance and Summary get the fix from the same ChargenPreviewRenderer facade — confirmed both pages call the identical gmCG3DView::Update on their own gmCG3DView instance, so no page-specific code was needed. Lighting was independently re-verified against the same function's SetLight call (DISTANT_LIGHT, intensity 2.0, direction (0.3, 1.9, 0.65), default white color) and found to already match byte-for-byte what CC6a shipped. Also files docs/ISSUES.md #409 for GF-16 (client-wide UI tooltip system), investigated in the same root-cause pass but explicitly out of this batch's scope, and marks it DEFERRED in the findings doc. Tests: 11 new/extended (ChargenPreviewEntityBuilderTests.TryBuildBackdrop_*, ChargenPreviewControllerTests backdrop rebuild/swap/absent/no-op cases, PrivateEntityViewportRendererDrawOrderTests pinning the paperdoll/creature- appraisal single-entity invariant). Live-DAT measurement: all 13 retail heritages' EnvironmentSetupId resolve to a real, drawable installed Setup. App suite 5307/3 -> 5321/3 (+14, 0 regressions). Runtime 1735/0 unchanged. Launcher.Core.Tests 337/0 and Launcher.Tests 67/0 unchanged (first build of the merged tree carrying the #406 launcher merge). Full solution: 14508 total / 14504 passed / 4 skipped / 0 failed, dotnet test exit code 0. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 53 +++ ...-08-16-campaign-cc-gate-round1-findings.md | 88 ++++- .../Rendering/ChargenPreviewController.cs | 27 +- .../Rendering/ChargenPreviewEntityBuilder.cs | 99 ++++++ .../Rendering/ChargenPreviewRenderer.cs | 17 +- .../PrivateEntityViewportRenderer.cs | 313 +++++++++++++----- .../ChargenPreviewControllerTests.cs | 174 ++++++++++ .../ChargenPreviewEntityBuilderTests.cs | 115 +++++++ ...ateEntityViewportRendererDrawOrderTests.cs | 83 +++++ 9 files changed, 866 insertions(+), 103 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererDrawOrderTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 39f1ed62..7c9515a2 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,59 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1) + +**Status:** OPEN +**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for ~253 authored elements client-wide and acdream shows none) + +Found during Campaign CC gate round 1's Batch D root-cause investigation +(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`, GF-16 +"Hover tooltips missing on all pages"). Explicitly out of Batch D's own +scope — Batch D fixed the chargen 3D preview backdrop (GF-7/GF-14) only; +GF-16 is a CLIENT-WIDE mechanism, not a chargen-scoped one, and needs its +own gate round the same way GF-12's frame carve-out and #408's +importer-wide honor did. + +Retail's tooltip pipeline (decomp anchors from the Batch D +investigation): + +- `UIElement::StartTooltipAtMouse @0x00460D70` — the per-element entry + point; fired from mouse-hover dispatch. +- `UIElementManager::StartTooltip @0x0045DE90` and a second call site + `@0x00459700` — the manager-level owner that actually builds/positions + the tooltip popup element and starts its show/delay timer. +- Layout DID `0x21000041` — the authored tooltip popup LayoutDesc (not yet + imported/mounted by `LayoutImporter`/`RetailUiRuntime`). +- Element properties `P0x47`/`P0x48`/`P0x49`/`P0x4A`/`P0x4B` — the five + per-element tooltip-text/behavior properties `UIElement::OnSetAttribute` + reads (exact semantics per property still need re-derivation when this + issue is picked up — the investigation only confirmed the property IDs, + not their individual meanings). +- Measured **~253 authored elements client-wide** carry at least one of + those five properties (a scope comparable to #408's 1,083-element sweep, + though a different property family). +- User-facing config: `Misc_TooltipEnable`/`Misc_TooltipDelay` prefs (the + Options-panel-adjacent settings that gate whether tooltips show at all + and how long the hover dwell is before one appears). + +Fix direction, mirroring #408's own "own gate round" shape: (1) grep-named +first on all four decomp anchors above and re-derive the exact show/hide/ +position/delay state machine (`StartTooltipAtMouse` → `StartTooltip` → +popup lifecycle) before writing any pseudocode; (2) import/mount layout +`0x21000041` through the existing `LayoutImporter`/`RetailUiRuntime` +pipeline; (3) wire client-wide mouse-hover dispatch (likely through the +existing `InputDispatcher`/`UiRoot` hover-tracking, if any already exists, +or a new hover-timer owner otherwise) to read the five P0x47-P0x4B +properties per hovered element; (4) honor `Misc_TooltipEnable`/ +`Misc_TooltipDelay` from `RuntimeCharacterOptionsState`/ +`CharacterOptionTable` (Campaign OP's existing option-storage owner); (5) +a live-DAT sweep of the ~253 elements (same shape as #408's per-LayoutDesc +enumeration) before claiming full coverage, since a partial per-page +implementation would repeat the "accumulate a bigger partial table" +mistake #306 already named for a different subsystem; (6) its own +connected visual gate — hovering a representative sample across multiple +screens (chargen, main game UI, chat, Options) side-by-side with retail. + ## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide) **Status:** OPEN diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index ddd591b8..27d65ed5 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -41,6 +41,23 @@ first time this batch — the user's own visual check of both is owed before considering Commit 2 closed; automated coverage cannot catch a purely visual placement regression.** +**Batch D (chargen 3D preview backdrop) is CODE-COMPLETE 2026-08-16, pending +the user's visual gate.** GF-7 and GF-14 are fixed — see each entry's own +FIXED note below. GF-16 (client-wide tooltips) was investigated in the same +root-cause pass but is explicitly out of this batch's scope — deferred as +`docs/ISSUES.md` #409 with its own decomp anchors. Fixture + live-DAT tests +only this round (no graphical client launch); App suite went from 5307/3 to +5321/3 (+14, zero regressions), Runtime 1735/0 unchanged. Both Launcher test +projects (this being the first build of the merged tree carrying the #406 +launcher merge) pass at their own baselines: Launcher.Core.Tests 337/0, +Launcher.Tests 67/0. Blast radius: `PrivateEntityViewportRenderer` (shared +with paperdoll and creature-appraisal) gained an OPTIONAL second entity +slot reserved via a `backdropRenderId` constructor parameter — paperdoll +and creature-appraisal never pass one, so their draw stays single-entity +by construction (`SetBackdrop` throws if called without a reserved slot, +and the entity-list-assembly helper `BuildDrawEntities` degrades to +exactly the main entity whenever no backdrop is configured/set). + User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, windowed). Screenshots: retail Heritage, acdream Heritage, retail Profession. The user's side-by-side retail reports are AXIOMS @@ -236,10 +253,43 @@ ISSUES.md; this doc is the six-page batch. AP-215's own icon-thumbnail item (the four icon-only spins still show no per-choice icon art — a DIFFERENT, still-open gap) is rewritten, not retired — see that row. -- **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14); - retail's chargen 3D view shows a scenic backdrop. (The Heritage-page - preview area shows terrain in BOTH clients — establish from the decomp - what actually renders behind the model per page/view.) +- **GF-7 Preview backdrop black on Appearance (and Summary, GF-14) — FIXED + (Campaign CC gate round 1, Batch D).** Root cause: retail's + `gmCG3DView::Update @0x004EE9D0` (~0x004eecd3-0x004eed44) constructs a + SECOND `CPhysicsObj` from the current heritage's own + `HeritageGroup_CG.environmentSetupID` field (verbatim struct layout, + `acclient.h`) and adds it to the SAME viewport's `creature_mode_objects` + the player object lives in — this codebase already parsed that id as + `ChargenHeritageOptions.EnvironmentSetupId` (`ChargenTableReader.cs`) + but never consumed it anywhere. The decompiler elides the actual field + read (`var_b8`/`eax_32`, an unresolved-call artifact — see + `claude-memory/feedback_bn_decomp_field_names.md`); cross-referencing + `acclient.h`'s `HeritageGroup_CG` struct (environmentSetupID sits right + after setupID) confirmed what the elided value is. The backdrop object + gets NO explicit position/orientation/scale — `CPhysicsObj::makeObject` + (0x004eed2f) leaves it at the scene origin with identity orientation, + same as the player object's own default placement, and retail's own + `AddObject` insertion order puts the backdrop BEFORE the player (the + player's own re-`AddObject` happens later, at ~0x004ef199, after the + full clothing ObjDesc composes). Fixed by extending + `ChargenPreviewEntityBuilder` with `TryBuildBackdrop` (builds a plain, + unposed Setup mesh from the heritage's `EnvironmentSetupId`, returning + null for id 0/unset or an unresolvable Setup — matching retail's own + `if (eax_32 != INVALID_DID.id)` gate at 0x004eed29), giving + `PrivateEntityViewportRenderer` an optional second entity slot + (`SetBackdrop`, reserved via a `backdropRenderId` constructor param so + paperdoll/creature-appraisal — which never pass one — cannot acquire a + second entity even by accident), and wiring `ChargenPreviewController` + to rebuild the backdrop whenever the HERITAGE changes (narrower than the + existing camera-eye-reset gate, since `environmentSetupID` is a pure + function of heritage, never gender or appearance selection). Both + Appearance and Summary get the fix from the SAME `ChargenPreviewRenderer` + facade — no page-specific code needed, confirmed both pages call the + identical `gmCG3DView::Update` on their own separate `gmCG3DView` + instance. Lighting was independently re-verified against the same + function's `SetLight` call (`DISTANT_LIGHT, 2.0, (0.3, 1.9, 0.65)` + direction, default white color) and found to ALREADY match byte-for-byte + what CC6a shipped — no lighting change was needed. - **GF-8 Appearance Face/Clothes sub-tab selection unmarked — FIXED (Campaign CC gate round 1, Batch B).** Same root and same fix as GF-1: the Face (`0x100003A9`)/Clothes (`0x100003AA`) sub-tab buttons author @@ -306,14 +356,22 @@ ISSUES.md; this doc is the six-page batch. this EXACT carve-out). Full App suite (5304 tests): zero regressions. **The user's own visual check of chat + the main game UI is still owed** — automated coverage cannot catch a purely visual placement regression. -- **GF-14 Summary paperdoll backdrop black** (same family as GF-7, - UNCHANGED, out of this batch's scope — the 3-D preview backdrop, not a - text-widget gap). **Summary textbox wrapper + scrollbar — FIXED (Batch - C, Commit 2 for the frame/build half, Commit 3 for the scrollbar LINK - and the how-to text's own content — see the Suspected-shared-roots - entry and Commit 3's own composition of `gmCGSummaryPage::SetHowToText` - into `0x10000404`).** +- **GF-14 Summary paperdoll backdrop black — FIXED (Campaign CC gate round + 1, Batch D, same fix as GF-7 above — both pages call the identical + `gmCG3DView::Update` on their own `gmCG3DView` instance).** **Summary + textbox wrapper + scrollbar — FIXED (Batch C, Commit 2 for the + frame/build half, Commit 3 for the scrollbar LINK and the how-to text's + own content — see the Suspected-shared-roots entry and Commit 3's own + composition of `gmCGSummaryPage::SetHowToText` into `0x10000404`).** - **GF-16 Hover tooltips missing on all pages** (retail pops tooltips). + DEFERRED to its own gate round — filed as + [`docs/ISSUES.md` #409](../ISSUES.md) with the decomp anchors + (`UIElement::StartTooltipAtMouse @0x00460D70`, + `UIElementManager::StartTooltip @0x0045DE90` + `@0x00459700`, layout DID + `0x21000041`, properties P0x47-P0x4B, ~253 authored elements, prefs + `Misc_TooltipEnable`/`Misc_TooltipDelay`) the Batch D investigation + surfaced. Out of Batch D's scope: it is a CLIENT-WIDE mechanism, not the + chargen 3D preview backdrop Batch D actually fixed (GF-7/GF-14 above). ## Suspected shared roots (to be CONFIRMED by the investigation, not assumed) @@ -349,7 +407,13 @@ ISSUES.md; this doc is the six-page batch. distinct from the art/media commit, and NOT gated by the same art- availability check `ActiveState` is). See each GF's own FIXED entry above and the retired AP-222 / narrowed AP-215 register rows. -4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws. +4. ~~Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws.~~ + CONFIRMED, CLOSED at Batch D: `gmCG3DView::Update`'s own + `m_pbgObject`/`m_bgSetupID` pair, sourced from the heritage's + `HeritageGroup_CG.environmentSetupID` field — already parsed into this + codebase as `ChargenHeritageOptions.EnvironmentSetupId` but never + consumed before this fix. See GF-7's own FIXED entry above for the full + decomp citation. 5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked chargen screen.~~ CLOSED: focus/typing routing was never broken (live- verified); the real cause was `RetailDialogFactory` never re-asserting diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index 4727cecb..42d4cfca 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -5,6 +5,7 @@ using AcDream.Content; using AcDream.Core.CharGen; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; +using AcDream.Core.World; using DatReaderWriter; namespace AcDream.App.Rendering; @@ -318,6 +319,27 @@ internal sealed class ChargenPreviewController : : ChargenPreviewCamera.ResolveDefaultEye(heritageId); } + // Batch D (GF-7/GF-14): retail's own backdrop-rebuild gate + // (gmCG3DView::Update's `m_bgSetupID.id != eax_32` check) fires + // whenever the HERITAGE's own environmentSetupID differs from the + // one currently shown — and that value is a pure function of + // heritage (ACCharGenData::GetHG(mHeritageGroup).environmentSetupID), + // never gender. Narrower than heritageOrGenderChanged on purpose: a + // gender-only change (or an appearance-only change, which never + // reaches this branch at all) would otherwise pay a redundant Setup + // dat fetch + mesh-reference acquire/release for a backdrop that + // cannot have changed. + bool heritageChanged = !_hasComposed || heritageId != _lastHeritageId; + if (heritageChanged) + { + WorldEntity? backdrop = + options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + ? ChargenPreviewEntityBuilder.TryBuildBackdrop( + _dats, heritage!.EnvironmentSetupId, _datLock) + : null; + _renderer.SetBackdrop(backdrop); + } + // ChargenPreviewZoomController's animator dependency is required at // construction (fix round F2) — a fresh animator means a fresh // controller, but it reads IsZoomedIn straight through the animator @@ -364,8 +386,11 @@ internal sealed class ChargenPreviewController : // the leased renderer holding it until the renderer's OWN disposal // (a separate manifest entry, one step later) — this class built // the entity via Rebuild, so it releases it on its own teardown - // instead of relying on a downstream owner to notice. + // instead of relying on a downstream owner to notice. Batch D: the + // backdrop entity is the SAME kind of controller-built resource, so + // it releases on the same teardown for the same reason. _renderer.SetPreview(null); + _renderer.SetBackdrop(null); _animator = null; _zoom = null; // The renderer itself is a leased composition resource disposed by diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs index df21c5a6..fdf74162 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -104,6 +104,19 @@ internal static class ChargenPreviewEntityBuilder /// doc comment. public const uint PreviewRenderId = 0xDA11_D032u; + /// Reserved synthetic guid for the chargen preview's ENVIRONMENT + /// backdrop (GF-7/GF-14 fix) — next slot in the same 0xDA11D03x chargen + /// family as . + public const uint PreviewBackdropServerGuid = 0xDA11_D033u; + + /// Reserved render-local entity id for the backdrop object, + /// passed in animatedEntityIds alongside + /// so a heritage switch's new environment Setup also bypasses the + /// classification cache — same reasoning as 's + /// own doc comment, applied to retail's SECOND creature_mode_objects + /// member (gmCG3DView::m_pbgObject). + public const uint PreviewBackdropRenderId = 0xDA11_D034u; + /// /// Retail's held-pose (REST) animation DID enum key, resolved through /// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid @@ -301,6 +314,92 @@ internal static class ChargenPreviewEntityBuilder }; } + /// + /// Builds the chargen preview's ENVIRONMENT BACKDROP entity — the fix for + /// GF-7/GF-14 (preview backdrop black on Appearance and Summary). + /// + /// + /// Decomp-cited: gmCG3DView::Update @0x004EE9D0 + /// (~0x004eecd3-0x004eed44) constructs a SECOND CPhysicsObj from + /// m_bgSetupID and adds it to the SAME viewport's + /// creature_mode_objects the player object lives in — BEFORE + /// the player is re-added (the player's own re-AddObject happens + /// much later, at ~0x004ef199, after the full clothing ObjDesc is + /// composed), so retail's own draw-list order is backdrop first, player + /// second. m_bgSetupID is compared against a freshly-read value the + /// decompiler elides (var_b8/eax_32, an unresolved-call + /// artifact — see claude-memory/feedback_bn_decomp_field_names.md) + /// immediately after ACCharGenData::GetHG(charGenData, mHeritageGroup) + /// (0x004eea1a) resolves the current heritage's HeritageGroup_CG; + /// acclient.h's verbatim struct layout + /// (HeritageGroup_CG.environmentSetupID, right after + /// setupID) confirms the elided value IS that field — i.e. THE + /// SAME id this codebase already parses as + /// + /// (ChargenTableReader.cs) but never consumed. The backdrop object + /// gets NO explicit position/orientation/scale anywhere in the function — + /// CPhysicsObj::makeObject(eax_32, 0, 1) (0x004eed2f) leaves it at + /// its physics-object default (origin, identity), exactly like the player + /// object's own placement in this same private scene. This method mirrors + /// that: a plain, unposed, unpalette-overridden Setup mesh at the origin. + /// + /// + /// + /// Both the Appearance page (gmCGAppearancePage) and the Summary + /// page (gmCGSummaryPage) call this SAME gmCG3DView::Update + /// function on their own gmCG3DView instance (confirmed at + /// pseudo-C ~0x0047bbf0/~0x0047c92c for Summary and ~0x0047c840/ + /// ~0x0047eee1 for Appearance) — so the backdrop mechanism is identical + /// for both viewports, not page-specific. + /// + /// + /// + /// . + /// Zero (unset/no environment authored for this heritage) returns null — + /// matches retail's own if (eax_32 != INVALID_DID.id) gate at + /// 0x004eed29, which skips makeObject/AddObject entirely + /// when the heritage has no environment Setup. + /// + public static WorldEntity? TryBuildBackdrop( + IDatReaderWriter dats, + uint environmentSetupId, + object datLock) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(datLock); + + if (environmentSetupId == 0u) + return null; + + lock (datLock) + { + Setup? setup = dats.Get(environmentSetupId); + if (setup is null) + return null; + + var flattened = SetupMesh.Flatten(setup); + var drawable = new List(flattened.Count); + foreach (MeshRef part in flattened) + { + if (dats.Get(part.GfxObjId) is not null) + drawable.Add(part); + } + if (drawable.Count == 0) + return null; + + return new WorldEntity + { + Id = PreviewBackdropRenderId, + ServerGuid = PreviewBackdropServerGuid, + SourceGfxObjOrSetupId = environmentSetupId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = drawable, + ParentCellId = null, + }; + } + } + /// No dat access — pure projection of the already-composed /// ObjDesc's subpalettes, safe to call outside datLock. private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance) diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs index 91d58ee9..d643e2b1 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -14,6 +14,15 @@ internal interface IChargenPreviewRenderer { void SetPreview(WorldEntity? entity); + /// + /// Sets or clears the environment backdrop entity drawn BEHIND the + /// preview (Campaign CC gate round 1 Batch D, GF-7/GF-14) — retail's + /// gmCG3DView::m_pbgObject. See + /// for the + /// decomp-cited placement. + /// + void SetBackdrop(WorldEntity? entity); + uint Render(int width, int height); } @@ -91,7 +100,11 @@ internal sealed class ChargenPreviewRenderer : meshAdapter, ChargenPreviewEntityBuilder.PreviewRenderId, _camera, - "chargen preview"); + "chargen preview", + // Batch D (GF-7/GF-14): reserves the second draw-entity slot for + // the heritage's environment Setup — see PrivateEntityViewportRenderer's + // own doc comment on backdropRenderId. + ChargenPreviewEntityBuilder.PreviewBackdropRenderId); } public bool TextureIsBottomUp => _renderer.TextureIsBottomUp; @@ -105,6 +118,8 @@ internal sealed class ChargenPreviewRenderer : public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity); + public void SetBackdrop(WorldEntity? entity) => _renderer.SetBackdrop(entity); + public uint Render(int width, int height) => _renderer.Render(width, height); public void Dispose() => _renderer.Dispose(); diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index 97db5e00..22cb8ca5 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -44,6 +44,20 @@ internal interface IPrivateEntityViewportCamera : ICamera /// raw-GL WbDrawDispatcher into (through V10, §5.5.6) was deleted at /// Campaign V slice V11: WbDrawDispatcher now records into the pass /// this renderer publishes on both call sites the same way. +/// +/// +/// Campaign CC gate round 1, Batch D (GF-7/GF-14). Retail's +/// gmCG3DView::Update @0x004EE9D0 draws a SECOND private entity — a +/// heritage-authored environment Setup (m_pbgObject) — behind the main +/// one, in the SAME creature_mode_objects list. This renderer now +/// supports that as an OPTIONAL second entity slot, reserved at construction +/// via -shaped ctor param (see below) — +/// paperdoll and creature-appraisal never pass one, so +/// throws for them rather than silently doing nothing (the slot does not +/// exist). See for +/// the full decomp citation of the backdrop's placement (unposed, at the +/// scene origin, added to the draw list BEFORE the main entity). +/// /// internal sealed class PrivateEntityViewportRenderer : IUiViewportRenderer, @@ -68,21 +82,23 @@ internal sealed class PrivateEntityViewportRenderer : private readonly WbDrawDispatcher _dispatcher; private readonly SceneLightingUboBinding _lightUbo; - private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly IWbMeshAdapter _meshAdapter; private readonly IPrivateEntityViewportCamera _camera; private readonly HashSet _animatedIds; private readonly string _diagnosticName; - private readonly List - _retiringMeshReferences = []; + + private readonly EntitySlot _mainSlot; + + /// Null for every renderer that never reserved a + /// backdropRenderId (paperdoll, creature-appraisal) — the backdrop + /// feature does not exist for them, not just "unused". + private readonly EntitySlot? _backdropSlot; private IGpuRenderTarget? _target; private IGpuSampler? _sampler; private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; private int _fbW; private int _fbH; - private WorldEntity? _entity; - private SyntheticEntityMeshReferenceOwner? _meshReferences; public PrivateEntityViewportRenderer( IWorldPassScope scope, @@ -94,10 +110,13 @@ internal sealed class PrivateEntityViewportRenderer : IWbMeshAdapter meshAdapter, uint renderId, IPrivateEntityViewportCamera camera, - string diagnosticName) + string diagnosticName, + uint? backdropRenderId = null) { if (renderId == 0u) throw new ArgumentOutOfRangeException(nameof(renderId)); + if (backdropRenderId == 0u) + throw new ArgumentOutOfRangeException(nameof(backdropRenderId)); _scope = scope ?? throw new ArgumentNullException( nameof(scope), @@ -112,10 +131,18 @@ internal sealed class PrivateEntityViewportRenderer : _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) ? "creature viewport" : diagnosticName; - _animatedIds = [renderId]; - _textureOwnerLease = new FixedEntityTextureOwnerLease( - textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)), - renderId); + + IEntityTextureLifetime textureLifetimeChecked = textureLifetime + ?? throw new ArgumentNullException(nameof(textureLifetime)); + + _mainSlot = new EntitySlot(_meshAdapter, textureLifetimeChecked, renderId, _diagnosticName); + _backdropSlot = backdropRenderId is uint backdropId + ? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop") + : null; + + _animatedIds = backdropRenderId is uint animatedBackdropId + ? [renderId, animatedBackdropId] + : [renderId]; } /// @@ -125,65 +152,27 @@ internal sealed class PrivateEntityViewportRenderer : /// public bool TextureIsBottomUp => false; - public void SetEntity(WorldEntity? entity) + public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); + + /// + /// Sets or clears the environment backdrop entity drawn BEHIND the main + /// entity — GF-7/GF-14's fix, retail's gmCG3DView::m_pbgObject. Only + /// valid on a renderer constructed with a backdropRenderId + /// ('s own construction); calling this + /// on a renderer that never reserved one (paperdoll, creature-appraisal) + /// throws — the slot does not exist for them, so there is nothing to make + /// "inert" by silently ignoring the call instead. + /// + public void SetBackdrop(WorldEntity? entity) { - ReleaseRetiringMeshReferences(); - - if (ReferenceEquals(_entity, entity)) - return; - - SyntheticEntityMeshReferenceOwner? replacement = null; - if (entity is not null) + if (_backdropSlot is null) { - replacement = new SyntheticEntityMeshReferenceOwner( - _meshAdapter, - CollectMeshIds(entity)); - replacement.Acquire(); + throw new InvalidOperationException( + $"The {_diagnosticName} was not constructed with a " + + "backdropRenderId and cannot render a second (backdrop) entity."); } - SyntheticEntityMeshReferenceOwner? previous = _meshReferences; - try - { - _textureOwnerLease.Replace(entity is not null); - } - catch (Exception textureFailure) - { - if (replacement is null) - throw; - - try - { - replacement.Dispose(); - } - catch (Exception rollbackFailure) - { - throw new AggregateException( - $"The {_diagnosticName} texture-owner replacement failed " - + "and the replacement mesh-owner rollback did not converge.", - textureFailure, - rollbackFailure); - } - - System.Runtime.ExceptionServices.ExceptionDispatchInfo - .Capture(textureFailure) - .Throw(); - } - - _meshReferences = replacement; - _entity = entity; - - if (previous is not null) - { - try - { - previous.Dispose(); - } - catch - { - _retiringMeshReferences.Add(previous); - throw; - } - } + _backdropSlot.Set(entity); } /// @@ -193,7 +182,7 @@ internal sealed class PrivateEntityViewportRenderer : /// public uint Render(int width, int height) { - WorldEntity? entity = _entity; + WorldEntity? entity = _mainSlot.Entity; if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) return 0u; @@ -232,7 +221,7 @@ internal sealed class PrivateEntityViewportRenderer : UploadCreatureLight(); - WorldEntity[] entities = [entity]; + IReadOnlyList drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity); var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] @@ -241,7 +230,7 @@ internal sealed class PrivateEntityViewportRenderer : PrivateLandblockId, new Vector3(-1024f), new Vector3(1024f), - entities, + drawEntities, null), }; @@ -255,9 +244,34 @@ internal sealed class PrivateEntityViewportRenderer : return UiTextureTableHandle.FromSlot(_slot); } + /// + /// Pure helper assembling this frame's draw-entity list in retail's own + /// insertion order — gmCG3DView::Update adds the backdrop object to + /// creature_mode_objects BEFORE the main (player) object is + /// re-added (the player's own re-AddObject happens much later, at + /// ~0x004ef199, after the full clothing ObjDesc composes — see + /// 's own decomp + /// citation). A null or empty-meshed backdrop degrades to exactly the main + /// entity — this is the paperdoll/creature-appraisal invariant (they never + /// configure a backdrop slot at all, so this always takes this branch for + /// them), pinned directly by + /// PrivateEntityViewportRendererDrawOrderTests without needing a + /// live GPU device or a constructed . + /// + internal static IReadOnlyList BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) => + backdrop is not null && backdrop.MeshRefs.Count > 0 + ? [backdrop, main] + : [main]; + /// /// Both retail paperdoll and creature examination call /// UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65)). + /// Byte-decoded confirmation (Batch D re-derivation): the SAME three + /// float32 constants (0x3e99999a/0x3ff33333/0x3F266666 + /// = 0.3/1.9/0.65) appear verbatim at gmCG3DView::Update's own + /// SetLight call site (pseudo-C ~0x004eecd3-0x004eece3) — the + /// chargen preview uses the EXACT same light this method already ported, + /// not a different value. /// private void UploadCreatureLight() { @@ -342,17 +356,10 @@ internal sealed class PrivateEntityViewportRenderer : public void Dispose() { - _entity = null; - if (_meshReferences is { } current) - { - _meshReferences = null; - _retiringMeshReferences.Add(current); - } - List? failures = null; try { - _textureOwnerLease.Dispose(); + _mainSlot.Dispose(); } catch (Exception error) { @@ -360,7 +367,7 @@ internal sealed class PrivateEntityViewportRenderer : } try { - ReleaseRetiringMeshReferences(); + _backdropSlot?.Dispose(); } catch (Exception error) { @@ -391,30 +398,158 @@ internal sealed class PrivateEntityViewportRenderer : yield return entity.PartOverrides[i].GfxObjId; } - private void ReleaseRetiringMeshReferences() + /// + /// One private entity's own mesh-reference/texture-owner lifetime, + /// independent of any other slot on the same renderer. Factored out at + /// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets + /// the EXACT SAME acquire/replace/retire behavior the main entity already + /// had — a single-owner class shared by both slots rather than a second, + /// hand-duplicated copy of 's + /// pre-Batch-D body. + /// + private sealed class EntitySlot { - List? failures = null; - for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--) + private readonly IWbMeshAdapter _meshAdapter; + private readonly FixedEntityTextureOwnerLease _textureOwnerLease; + private readonly string _diagnosticName; + private readonly List _retiringMeshReferences = []; + + private SyntheticEntityMeshReferenceOwner? _meshReferences; + + public EntitySlot( + IWbMeshAdapter meshAdapter, + IEntityTextureLifetime textureLifetime, + uint ownerLocalId, + string diagnosticName) { - SyntheticEntityMeshReferenceOwner owner = - _retiringMeshReferences[i]; + _meshAdapter = meshAdapter; + _textureOwnerLease = new FixedEntityTextureOwnerLease(textureLifetime, ownerLocalId); + _diagnosticName = diagnosticName; + } + + public WorldEntity? Entity { get; private set; } + + public void Set(WorldEntity? entity) + { + ReleaseRetiringMeshReferences(); + + if (ReferenceEquals(Entity, entity)) + return; + + SyntheticEntityMeshReferenceOwner? replacement = null; + if (entity is not null) + { + replacement = new SyntheticEntityMeshReferenceOwner( + _meshAdapter, + CollectMeshIds(entity)); + replacement.Acquire(); + } + + SyntheticEntityMeshReferenceOwner? previous = _meshReferences; try { - owner.Dispose(); - if (owner.IsDisposed) - _retiringMeshReferences.RemoveAt(i); + _textureOwnerLease.Replace(entity is not null); + } + catch (Exception textureFailure) + { + if (replacement is null) + throw; + + try + { + replacement.Dispose(); + } + catch (Exception rollbackFailure) + { + throw new AggregateException( + $"The {_diagnosticName} texture-owner replacement failed " + + "and the replacement mesh-owner rollback did not converge.", + textureFailure, + rollbackFailure); + } + + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(textureFailure) + .Throw(); + } + + _meshReferences = replacement; + Entity = entity; + + if (previous is not null) + { + try + { + previous.Dispose(); + } + catch + { + _retiringMeshReferences.Add(previous); + throw; + } + } + } + + public void Dispose() + { + Entity = null; + if (_meshReferences is { } current) + { + _meshReferences = null; + _retiringMeshReferences.Add(current); + } + + List? failures = null; + try + { + _textureOwnerLease.Dispose(); } catch (Exception error) { (failures ??= []).Add(error); } + try + { + ReleaseRetiringMeshReferences(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + + if (failures is not null) + { + throw new AggregateException( + $"The {_diagnosticName} resources did not fully release.", + failures); + } } - if (failures is not null) + private void ReleaseRetiringMeshReferences() { - throw new AggregateException( - $"One or more {_diagnosticName} mesh owners remain pending.", - failures); + List? failures = null; + for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--) + { + SyntheticEntityMeshReferenceOwner owner = + _retiringMeshReferences[i]; + try + { + owner.Dispose(); + if (owner.IsDisposed) + _retiringMeshReferences.RemoveAt(i); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (failures is not null) + { + throw new AggregateException( + $"One or more {_diagnosticName} mesh owners remain pending.", + failures); + } } } } diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs index af7892f8..204b3740 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs @@ -204,6 +204,168 @@ public sealed class ChargenPreviewControllerTests } } + /// + /// Campaign CC gate round 1, Batch D (GF-7/GF-14): a successful Rebuild + /// against a heritage that authors an environment Setup pushes a non-null + /// backdrop entity to the renderer, sourced from the heritage's own + /// EnvironmentSetupId. + /// + [InstalledDatFact] + public void Rebuild_HeritageWithEnvironmentSetupId_SetsANonNullBackdrop() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian)); + if (aluvian!.EnvironmentSetupId == 0u) + { + _out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId."); + return; + } + + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + + Assert.Equal(1, renderer.SetBackdropCallCount); + Assert.NotNull(renderer.LastBackdropEntity); + Assert.Equal(aluvian.EnvironmentSetupId, renderer.LastBackdropEntity!.SourceGfxObjOrSetupId); + } + } + + /// Retail's own gate (0x004eed29) skips the backdrop object + /// entirely for a heritage with no authored environment Setup — the + /// controller must leave the renderer's backdrop null, not build an empty + /// placeholder entity. + [InstalledDatFact] + public void Rebuild_HeritageWithNoEnvironmentSetupId_LeavesTheBackdropAbsent() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian)); + + // Synthetic zero-EnvironmentSetupId heritage, otherwise identical + // to the real installed Aluvian entry (so ChargenAppearanceFactory + // .TryCompose still succeeds against the SAME options instance) — + // proves the absent-when-unset path without depending on the + // installed dat happening to have an unset heritage. + var heritages = new Dictionary(options.HeritagesById) + { + [AluvianId] = aluvian! with { EnvironmentSetupId = 0u }, + }; + ChargenOptions zeroed = options with { HeritagesById = heritages }; + + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + Assert.True(controller.Rebuild( + zeroed, AluvianId, 1, DefaultSelection(zeroed, AluvianId, 1))); + + Assert.Equal(1, renderer.SetBackdropCallCount); + Assert.Null(renderer.LastBackdropEntity); + } + } + + /// + /// The backdrop swaps to the new heritage's own environment Setup on a + /// heritage change — the SAME gmCG3DView::Update gate + /// (m_bgSetupID.id != eax_32) that drives the main entity's own + /// re-dress. + /// + [InstalledDatFact] + public void Rebuild_HeritageChange_SwapsTheBackdropEntity() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian)); + if (aluvian!.EnvironmentSetupId == 0u) + { + _out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId."); + return; + } + if (!options.TryGetHeritage(GearknightId, out ChargenHeritageOptions? gearknight) + || gearknight!.GendersByKey.Count == 0 + || gearknight.EnvironmentSetupId == 0u) + { + _out.WriteLine("SKIP: installed dat has no usable Gearknight environment/gender to switch to."); + return; + } + + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + WorldEntity? firstBackdrop = renderer.LastBackdropEntity; + Assert.NotNull(firstBackdrop); + + int gearknightGender = gearknight.GendersByKey.Keys.First(); + Assert.True(controller.Rebuild( + options, GearknightId, gearknightGender, + DefaultSelection(options, GearknightId, gearknightGender))); + + Assert.Equal(2, renderer.SetBackdropCallCount); + Assert.NotSame(firstBackdrop, renderer.LastBackdropEntity); + Assert.NotNull(renderer.LastBackdropEntity); + Assert.Equal( + gearknight.EnvironmentSetupId, renderer.LastBackdropEntity!.SourceGfxObjOrSetupId); + } + } + + /// + /// Decomp-cited: the heritage's own environmentSetupID is a pure + /// function of heritage (HeritageGroup_CG), never gender or + /// appearance selection — an appearance-only Rebuild must not re-touch + /// the backdrop at all. + /// + [InstalledDatFact] + public void Rebuild_AppearanceOnlyChange_DoesNotRebuildTheBackdrop() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1); + Assert.True(controller.Rebuild(options, AluvianId, 1, first)); + Assert.Equal(1, renderer.SetBackdropCallCount); + + ChargenAppearanceSelection second = first with { SkinShade = 0.9 }; + Assert.True(controller.Rebuild(options, AluvianId, 1, second)); + + Assert.Equal(1, renderer.SetBackdropCallCount); + } + } + [InstalledDatFact] public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication() { @@ -272,12 +434,24 @@ public sealed class ChargenPreviewControllerTests public int SetPreviewCallCount { get; private set; } public int RenderCallCount { get; private set; } + /// Batch D (GF-7/GF-14): last value passed to . + /// Null both before the first call AND after an explicit clear — tests + /// distinguish the two via . + public WorldEntity? LastBackdropEntity { get; private set; } + public int SetBackdropCallCount { get; private set; } + public void SetPreview(WorldEntity? entity) { LastEntity = entity; SetPreviewCallCount++; } + public void SetBackdrop(WorldEntity? entity) + { + LastBackdropEntity = entity; + SetBackdropCallCount++; + } + public uint Render(int width, int height) { RenderCallCount++; diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs index 4e59b9a5..abebc743 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -275,4 +275,119 @@ public sealed class ChargenPreviewEntityBuilderTests Assert.False(animator.IsZoomedIn); Assert.NotEmpty(animator.Entity.MeshRefs); } + + /// + /// Campaign CC gate round 1, Batch D (GF-7/GF-14): the ENVIRONMENT + /// backdrop entity resolves against the installed dat for a real + /// heritage. Aluvian's own EnvironmentSetupId was already parsed + /// () but never consumed before this fix. + /// + [Fact] + public void TryBuildBackdrop_AluvianHeritage_ResolvesANonEmptyMesh() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian. + if (aluvian!.EnvironmentSetupId == 0u) + { + _out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId."); + return; + } + + var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop( + adapter, aluvian.EnvironmentSetupId, new object()); + + Assert.NotNull(entity); + Assert.NotEmpty(entity!.MeshRefs); + Assert.Equal(aluvian.EnvironmentSetupId, entity.SourceGfxObjOrSetupId); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewBackdropServerGuid, entity.ServerGuid); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewBackdropRenderId, entity.Id); + // Decomp-cited (gmCG3DView::Update ~0x004eed2f): CPhysicsObj::makeObject + // never receives an explicit position/orientation for the backdrop — + // it sits at the private scene's origin with identity orientation, + // same as the player object's own default placement. + Assert.Equal(Vector3.Zero, entity.Position); + Assert.Equal(Quaternion.Identity, entity.Rotation); + + _out.WriteLine($"backdropSetup=0x{aluvian.EnvironmentSetupId:X8} meshRefs={entity.MeshRefs.Count}"); + } + + /// Retail's own gate at 0x004eed29 (if (eax_32 != INVALID_DID.id)) + /// skips creating a backdrop object entirely when the heritage authors no + /// environment Setup — id 0/unset must return null, not an empty entity. + [Fact] + public void TryBuildBackdrop_UnsetEnvironmentSetupId_ReturnsNull() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop(adapter, 0u, new object()); + + Assert.Null(entity); + } + + [Fact] + public void TryBuildBackdrop_UnknownSetupId_ReturnsNull() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop( + adapter, 0x0200_FFFFu, new object()); + + Assert.Null(entity); + } + + /// + /// Live-DAT measurement (not assumed): every one of the 13 retail + /// heritages' EnvironmentSetupId resolves to a real installed + /// Setup with at least one drawable part. Reports precisely which + /// heritage(s) don't, if any, instead of assuming full coverage. + /// + [Fact] + public void TryBuildBackdrop_AllThirteenHeritages_ResolveOrAreReportedByName() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.Equal(13, options.HeritagesById.Count); + + var unresolved = new List(); + foreach (KeyValuePair pair in options.HeritagesById) + { + ChargenHeritageOptions heritage = pair.Value; + var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop( + adapter, heritage.EnvironmentSetupId, new object()); + if (entity is null) + { + unresolved.Add( + $"{heritage.Name} (id={pair.Key}, environmentSetupId=0x{heritage.EnvironmentSetupId:X8})"); + } + } + + _out.WriteLine(unresolved.Count == 0 + ? "All 13 heritages resolved a drawable environment backdrop." + : "Unresolved: " + string.Join("; ", unresolved)); + + // Measured, not assumed: report the exact set rather than asserting + // blind 13/13 in case the installed dat is missing one. + Assert.True( + unresolved.Count <= 13, + "Sanity bound only — the WriteLine above is the real measurement."); + } } diff --git a/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererDrawOrderTests.cs b/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererDrawOrderTests.cs new file mode 100644 index 00000000..ea07fae2 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererDrawOrderTests.cs @@ -0,0 +1,83 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign CC gate round 1, Batch D (GF-7/GF-14): pins +/// , the pure +/// helper that decides which entities +/// submits to WbDrawDispatcher. Exercised directly (no GPU device, no +/// constructed WbDrawDispatcher) — the same interface-fake-first +/// testing shape CreatureAppraisalPresentationTests already uses for +/// this renderer family, since PrivateEntityViewportRenderer itself +/// pulls in a live mesh-pipeline object graph too heavy to construct in a +/// unit test. +/// +/// +/// This is the paperdoll/creature-appraisal REGRESSION PIN the batch's test +/// plan calls for: both renderers never configure a backdrop slot (see +/// PaperdollViewportRenderer/CreatureAppraisalPresentation.cs — +/// neither passes a backdropRenderId nor exposes SetBackdrop), +/// so every one of their draws calls this helper with backdrop: null — +/// exactly the first case below. +/// +/// +public sealed class PrivateEntityViewportRendererDrawOrderTests +{ + private static WorldEntity Entity(uint id, IReadOnlyList meshRefs) => new() + { + Id = id, + ServerGuid = id, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = meshRefs, + }; + + private static readonly MeshRef[] OneMesh = [new MeshRef(0x0100_0001u, Matrix4x4.Identity)]; + + [Fact] + public void NoBackdrop_ReturnsExactlyTheMainEntity() + { + WorldEntity main = Entity(1u, OneMesh); + + IReadOnlyList entities = + PrivateEntityViewportRenderer.BuildDrawEntities(backdrop: null, main); + + Assert.Same(main, Assert.Single(entities)); + } + + [Fact] + public void BackdropPresent_ReturnsBackdropFirstThenMain() + { + // Decomp-cited: gmCG3DView::Update adds the backdrop object to + // creature_mode_objects BEFORE the player object is re-added + // (~0x004eed44 vs ~0x004ef199) — this ordering is retail-faithful, + // not an arbitrary choice. + WorldEntity backdrop = Entity(ChargenPreviewEntityBuilder.PreviewBackdropRenderId, OneMesh); + WorldEntity main = Entity(ChargenPreviewEntityBuilder.PreviewRenderId, OneMesh); + + IReadOnlyList entities = + PrivateEntityViewportRenderer.BuildDrawEntities(backdrop, main); + + Assert.Equal(2, entities.Count); + Assert.Same(backdrop, entities[0]); + Assert.Same(main, entities[1]); + } + + [Fact] + public void BackdropWithNoDrawableMeshes_DegradesToExactlyTheMainEntity() + { + WorldEntity emptyBackdrop = Entity( + ChargenPreviewEntityBuilder.PreviewBackdropRenderId, []); + WorldEntity main = Entity(ChargenPreviewEntityBuilder.PreviewRenderId, OneMesh); + + IReadOnlyList entities = + PrivateEntityViewportRenderer.BuildDrawEntities(emptyBackdrop, main); + + Assert.Same(main, Assert.Single(entities)); + } +} From 2ad805469d496cecc57a9c5c7e70600973d3649d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 13:24:36 +0200 Subject: [PATCH 121/138] docs: Campaign CC gate round 1 re-test findings R2-1..R2-8 Co-Authored-By: Claude Fable 5 --- ...-08-16-campaign-cc-gate-round1-findings.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 27d65ed5..83156c6f 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1,5 +1,54 @@ # Campaign CC connected gate — round 1 findings (2026-08-16) +## ROUND 1 RE-TEST (build `1.0.2-cc.i`, post-Batches B/C/D) — findings R2-1..R2-8 + +User's second visual pass with retail side-by-side screenshots (heritage +description, profession template text, "Attribute\n Credits" overlap, +skills credits overlap, retail skill-info box, retail Skills page, the +acdream GradCircle vs retail's color wheel). + +- **R2-1 (COMMON, regression from Batch C): description-box text + misaligned LEFT, clipping outside the frame** on Heritage, Profession, + Appearance, Town, Summary — first characters cut ("rained Starting + Skills", "OW HUNTERS", "ump, Loyalty"). Pre-Batch-C (cc.e screenshots) + the text started INSIDE the box. One shared cause suspected: the + rich-text/un-consume changes moved the text draw origin to the + element's outer rect where retail insets to an interior text region + (authored margins or interior-relative origin). PIN IT with a probe + before fixing. +- **R2-2: `Attribute\n Credits` renders the LITERAL `\n`** (UiButton + captions never escape-normalize — only BuildText does), AND the value + ("24") overlaps the caption text — the ValueLabel is not drawing in + its authored child rect. +- **R2-3: Skills "Available Skill Credits" value overlaps mid-caption** + ("Available Skill Credit0Credits") — same ValueLabel-rect family as + R2-2. +- **R2-4: Skills page functional gaps (retail screenshots 5-6):** + (a) rows are NOT selectable — retail selection turns the row brighter + white AND writes the skill's info into the lower-left description box + ("Loyalty (5) / Affects experience given to your patron... / Training + Bonus +5 / Formula :") — the info panes `0x100003FB/FC` are still + never written (investigation root-1c, missed by Batch C's scope); + (b) NOT divided into the four retail buckets (Specialized / Trained / + Useable Untrained / Unuseable Untrained with headers) — the user's + gate OVERTURNS AP-213's remaining flat-list half: implement the + buckets; (c) the skill list's scrollbar is missing. +- **R2-5: the color wheel renders as static authored art** (mirror-like + disc) where retail shows the gradient wheel + gold swatch dots that + CHANGE per selected part — the user's gate PROMOTES AP-216/AP-217's + remaining halves (real palette-color swatch rendering + gradient tint) + from partial-closed to must-port. +- **R2-6: Town description text misaligned** — R2-1 family. +- **R2-7: Summary — (a) text misaligned (R2-1); (b) the summary OVERVIEW + listbox is missing its scrollbar; (c) the how-to box's scrollbar + renders but OVERLAPS the text area and has no thumb ("slider").** +- **R2-8: the name field should show `[ Name ]`** — the user re-asserts + retail's prefill. Batch A verified retail's CODE never writes it; the + UNCHECKED hypothesis is the field's AUTHORED initial text (`P0x17` on + `0x10000402`) — probe the DAT; if authored, render authored initial + text (display-only; the committed state name stays empty, retail's + NameInputFilter forbids `[` so it can never be committed as a name). + **MILESTONE (2026-08-16, post-Batch-A build `1.0.2-cc.g`): the user completed the FIRST LIVE CHARACTER CREATE from acdream against local ACE — launcher → character select → Create → six pages → name → Finish → real From e24ec2088203097d6f92c4696d9561f52faa14d6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 14:07:19 +0200 Subject: [PATCH 122/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20E=20=E2=80=94=20text=20origin,=20caption=20?= =?UTF-8?q?escapes,=20value=20rects,=20scrollbars,=20name=20prefill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-1/R2-6 (description-box text clipped left of the frame, regressed from Batch C's frame un-consume): root cause was never the un-consume change itself — the Heritage/Profession/Town/Summary description boxes (0x100003C4/0x100003E0/0x10000409/0x10000404) all author retail's four independent text-inset margins (dat properties 0x23-0x26, UIElement_Text::OnSetAttribute cases 0xf-0x12: margL=9/margR=26/margU=15/ margD=15), which this codebase never read at all, before or after Batch C. Un-consuming the gold-frame children just made the pre-existing missing- margin bug visible for the first time (the frame's own left border now draws around the same x=0 origin text always used). Fixed end to end: ElementInfo.MarginLeft/Right/Top/Bottom (read in ApplyCanonicalLegacyProjection, propagated in Merge), UiText.MarginLeft/ Right/Top/Bottom (additive with the pre-existing Padding), a new pure UiText.ContentOffsetX static consumed by the multi-line draw path's per-line placement, and matching wrap-width shrinkage in DatRichText.Compose and BuildText's own authored-multiline path. Scoped to the multi-line (non-OneLine) path only. R2-2/R2-3 (Attribute\n Credits renders the literal backslash-n; the live credit value overlaps mid-caption): two stacked gaps. (1) UiButton captions never escape-normalized the DAT's literal "\n" — centralized the normalize into DatWidgetFactory's ResolveAuthoredString (the one choke point every P0x17 resolution already shares) plus a NormalizeEscapes helper for the per-state caption loop, so every caller normalizes identically. (2) UiButton.Label only ever drew one line — retail's UIElement_Button IS a UIElement_Text with OneLine=false on these buttons, so a caption should word-wrap/stack like any other Type-12 box. Added UiButton.DrawBlockLabel + the pure, unit-tested WrapBlockLines. The value-overlap itself: ValueBox was never wrong (live-DAT-measured correct child rects) — the caption was drawing unconfined across the button's full width ("Available Skill Credits" measures 193px in a 231px button whose value box starts at x=116). Fixed by confining the caption's own drawable width to stop before ValueBox.X whenever a ValueLabel coexists. R2-7a (Summary overview listbox missing its scrollbar): pure wiring gap — the listbox authors a linked scrollbar via dat property 0x72 (ScrollbarElementId=0x10000401) that CharacterCreationSummaryPage's constructor never resolved, unlike every other UiTemplateListBox owner in the codebase. Fixed with the same resolve-and-wire pattern. R2-7b (how-to box scrollbar overlaps text, no thumb): traced to a downstream symptom of R2-1, not an independent bug — UiScrollbar only paints its thumb when the linked model has overflow, and the pre-fix wrap width (un-inset) produced fewer/shorter lines than fit the view. Pinned directly against the real installed strings/font (Aluvian's how-to text) that the margin-correct width overflows. No UiScrollbar code changed. R2-8 (name field should show "[ Name ]"): re-checked the one hypothesis Batch A's GF-15 closure left open — an authored initial-text string on the field's own P0x17. Confirmed absent on every state in the installed DAT. No code change; Batch A's closure stands, now pinned as a live-DAT regression test. App suite 5334/3 (was 5321/3, +13, zero regressions). Runtime 1735/0 unchanged. Full solution Release build green. Co-Authored-By: Claude Fable 5 --- ...-08-16-campaign-cc-gate-round1-findings.md | 184 +++++++++++++++- .../UI/Layout/CharacterCreationSummaryPage.cs | 18 ++ src/AcDream.App/UI/Layout/DatRichText.cs | 10 +- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 46 +++- src/AcDream.App/UI/Layout/ElementReader.cs | 44 ++++ src/AcDream.App/UI/UiButton.cs | 128 ++++++++++- src/AcDream.App/UI/UiText.cs | 62 +++++- .../Layout/CharacterCreationLiveDatTests.cs | 208 ++++++++++++++++++ .../UI/Layout/DatRichTextTests.cs | 19 ++ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 136 ++++++++++++ tests/AcDream.App.Tests/UI/UiTextTests.cs | 63 ++++++ 11 files changed, 895 insertions(+), 23 deletions(-) diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 83156c6f..bf0e521c 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -15,14 +15,14 @@ acdream GradCircle vs retail's color wheel). rich-text/un-consume changes moved the text draw origin to the element's outer rect where retail insets to an interior text region (authored margins or interior-relative origin). PIN IT with a probe - before fixing. + before fixing. — **FIXED (Batch E), see below.** - **R2-2: `Attribute\n Credits` renders the LITERAL `\n`** (UiButton captions never escape-normalize — only BuildText does), AND the value ("24") overlaps the caption text — the ValueLabel is not drawing in - its authored child rect. + its authored child rect. — **FIXED (Batch E), see below.** - **R2-3: Skills "Available Skill Credits" value overlaps mid-caption** ("Available Skill Credit0Credits") — same ValueLabel-rect family as - R2-2. + R2-2. — **FIXED (Batch E), see below.** - **R2-4: Skills page functional gaps (retail screenshots 5-6):** (a) rows are NOT selectable — retail selection turns the row brighter white AND writes the skill's info into the lower-left description box @@ -32,22 +32,182 @@ acdream GradCircle vs retail's color wheel). (b) NOT divided into the four retail buckets (Specialized / Trained / Useable Untrained / Unuseable Untrained with headers) — the user's gate OVERTURNS AP-213's remaining flat-list half: implement the - buckets; (c) the skill list's scrollbar is missing. + buckets; (c) the skill list's scrollbar is missing. — OUT OF SCOPE for + Batch E (functional gap, not text layout); still open. - **R2-5: the color wheel renders as static authored art** (mirror-like disc) where retail shows the gradient wheel + gold swatch dots that CHANGE per selected part — the user's gate PROMOTES AP-216/AP-217's remaining halves (real palette-color swatch rendering + gradient tint) - from partial-closed to must-port. -- **R2-6: Town description text misaligned** — R2-1 family. + from partial-closed to must-port. — OUT OF SCOPE for Batch E; still + open. +- **R2-6: Town description text misaligned** — R2-1 family. — **FIXED + (Batch E)**, same shared mechanism as R2-1. - **R2-7: Summary — (a) text misaligned (R2-1); (b) the summary OVERVIEW listbox is missing its scrollbar; (c) the how-to box's scrollbar - renders but OVERLAPS the text area and has no thumb ("slider").** + renders but OVERLAPS the text area and has no thumb ("slider").** — + **FIXED (Batch E), see below** — (a) via the shared R2-1 mechanism; + (b) the listbox's own `0x72` scrollbar linkage was simply never wired + (every other `UiTemplateListBox` owner in the codebase already does + this — this page was the one holdout); (c) traced to a DOWNSTREAM + symptom of R2-1, not an independent bug — see the Batch E write-up for + the full geometric argument. - **R2-8: the name field should show `[ Name ]`** — the user re-asserts retail's prefill. Batch A verified retail's CODE never writes it; the UNCHECKED hypothesis is the field's AUTHORED initial text (`P0x17` on `0x10000402`) — probe the DAT; if authored, render authored initial text (display-only; the committed state name stays empty, retail's - NameInputFilter forbids `[` so it can never be committed as a name). + NameInputFilter forbids `[` so it can never be committed as a name). — + **RE-CHECKED (Batch E): NOT authored** — see below. Batch A's closure + stands; no code change. + +**Batch E (gate round 1, text layout correctness) is CODE-COMPLETE +2026-08-16, pending the user's visual gate.** R2-1/R2-2/R2-3/R2-6/R2-7 are +fixed at the mechanism level (no per-page nudges); R2-8 was re-checked and +confirmed NOT a code change. R2-4/R2-5 are explicitly out of scope +(functional gaps, not text layout) and remain open for a later round. + +- **R2-1/R2-6 root cause CONFIRMED, not the un-consume change itself:** + live-DAT-probed against the installed EoR dat, the Heritage/Profession/ + Town/Summary description boxes (`0x100003C4`/`0x100003E0`/`0x10000409`/ + `0x10000404`) all author retail's four independent text-inset margins + (dat properties `0x23`/`0x24`/`0x25`/`0x26` — `UIElement_Text:: + OnSetAttribute @0x0046a640` cases `0xf`-`0x12`, i.e. + `BaseProperty::GetPropertyName(arg2) - 0x14`, writing `m_margL`/ + `m_margR`/`m_margU`/`m_margD`): `margL=9, margR=26, margU=15, margD=15` + on every one of the four boxes (one shared authored template). This + codebase never read those four properties AT ALL, before OR after Batch + C — `UiText.Padding` (the only inset this port had) always defaults to + 0 for DAT-built text, so every box's text drew flush against x=0 + regardless of batch. The regression's actual TRIGGER was Batch C + un-consuming the gold-frame children (previously silently dropped): the + frame's own left border piece (`0x100002DE`/`0x100000E8`, live-DAT- + measured ~0-34px wide) now draws on top of/around the SAME x=0 origin + text has ALWAYS used, making the pre-existing missing-margin bug visible + for the first time. Fixed by adding the four margin properties end to + end: `ElementInfo.MarginLeft/Right/Top/Bottom` (read in + `ElementReader.ApplyCanonicalLegacyProjection`, propagated in `Merge` + with the same "non-zero derived wins" convention as `FontDid`), new + `UiText.MarginLeft/Right/Top/Bottom` properties (additive with the + pre-existing `Padding`, seeded by `DatWidgetFactory.BuildText`), and a + new pure `UiText.ContentOffsetX` static (mirrors `ContentBaseY`/ + `VOffset`'s own shape) consumed by the multi-line scrollable draw path's + per-line horizontal placement. `DatRichText.Compose`'s and + `DatWidgetFactory.BuildText`'s own authored-multiline wrap-width + formulas both shrink by the same `Padding+MarginLeft`/ + `Padding+MarginRight` inset — the wrap half of the regression (text + also overflowing the visible RIGHT edge, not just clipping on the left). + Deliberately scoped to the multi-line (non-`OneLine`) path only — the + static Centered/RightAligned/OneLine single-line branches keep their + pre-fix bare-`Padding` math, since every currently-broken box is + multi-line and touching those paths too would widen this fix's blast + radius with no known-broken target. R2-1's finding also named + "Appearance" — no Appearance-page description box exists in this + codebase (only Heritage/Profession/Town/Summary call + `DatRichText.Compose`); read as either a recollection slip or referring + to a page that will inherit this same fix automatically once/if it ever + grows one, since the fix lives in the shared `UiText`/`DatRichText` + mechanism, not per-page code. +- **R2-2/R2-3 root cause CONFIRMED, two stacked gaps:** (1) `UiButton` + captions never escape-normalized the DAT's literal two-character `\n` + escape — only `DatWidgetFactory.BuildText`'s own authored-string path + did. Centralized the normalize into the ONE choke point every P0x17 + caption resolution in `DatWidgetFactory.cs` already shares + (`ResolveAuthoredString`, plus a `NormalizeEscapes` helper for the + per-STATE caption loop that resolves a state's own `0x17` directly) — + every caller (`BuildText`, `BuildButton`'s own caption AND its lifted- + child caption, `BuildButton`'s coexisting `ValueLabel`, `BuildCheckbox`, + the per-state caption swap) now normalizes identically, closing the + exact "some callers normalize, some don't" class of bug that caused + this regression in the first place. (2) `UiButton.Label` only ever drew + ONE line, unconditionally — but retail's `UIElement_Button` IS a + `UIElement_Text` (`struct UIElement_Button : UIElement_Text`, + `acclient.h`) and these captions author `OneLine=false` + (live-DAT-probe-confirmed on `0x100003e2-e5`/`0x100003f9`), so a + caption that carries a newline OR simply doesn't fit its available + width should lay out as multiple stacked lines, the same word-wrap + every other Type-12 text box already gets (`UiText.WrapWords`). Added + `UiButton.DrawBlockLabel`/the pure, unit-tested `UiButton.WrapBlockLines` + extraction. The VALUE-overlap half specifically (R2-2's "24dits", R2-3's + "Credit0Credits"): `ValueBox` itself was NEVER null/wrong — live-DAT- + measured, both buttons' value children (`0x100002F1`/`0x100002F3` + family) resolve correctly. The overlap was the CAPTION drawing + unconfined across the button's FULL width (`Available Skill Credits` + measures 193px in the Skills button's 231px-wide box whose value box + starts at local x=116 — the caption's own unwrapped single-line render + reached x≈196, well past the value's territory). Fixed by confining the + caption's OWN drawable width to stop before `ValueBox.X` whenever a + `ValueLabel` coexists (`LabelBox`/`ValueBox` are mutually exclusive by + construction, so this never fights GF-11c's own `LabelBox` confinement). + A single-line caption that already fits draws with byte-identical + geometry to the pre-fix math — the fix is a strict superset for every + already-correct button caption in the client. +- **R2-7a root cause CONFIRMED — pure wiring gap, same shape as every + other holdout in this codebase:** the Summary OVERVIEW listbox + (`0x10000400`) authors a linked scrollbar via dat property `0x72` + (live-DAT-probe-confirmed `ScrollbarElementId=0x10000401`, a SIBLING + element, not a descendant of the listbox). Every OTHER + `UiTemplateListBox` owner in this codebase (`SocialFriendsPageController`, + `ConfigOptionsPageController`, the Fellowship/Allegiance/Squelch pages) + already resolves `ScrollbarElementId` against its page root and wires + `.Model = listBox.Scroll` — `CharacterCreationSummaryPage`'s + constructor was the one holdout that only ever wired the HOW-TO box's + own scrollbar (Batch C Commit 3) and never resolved this one. Fixed by + adding the identical resolve-and-wire block to the constructor. +- **R2-7b root cause CONFIRMED as a DOWNSTREAM SYMPTOM of R2-1, not an + independent defect** — investigated, not assumed: `UiScrollbar`'s own + draw path only paints the thumb `if (m.HasOverflow)` + (`ContentHeight > ViewHeight` on the linked `UiScrollable`). Before the + R2-1 fix, the how-to box's wrap width used the box's raw, un-inset + Width (247px) instead of the authored margin-inset content width + (247-9-26=212px) — a WIDER wrap width produces FEWER/SHORTER lines, + which can leave `ContentHeight <= ViewHeight` (no overflow → the thumb + legitimately has nothing to gate on and correctly draws nothing). Pinned + directly against the real installed strings/font (Aluvian's how-to + text, the longest composed variant — `SummaryHowTo` + the male name- + suggestion list + `SummaryHowToEnd` — at the box's real font, + `0x40000009`): composed with the CORRECT margin-inset width, the + content (multiple lines × the font's line height) exceeds the + margin-inset view height, so `HasOverflow` is true and the thumb draws. + No `UiScrollbar` code changed — this is a full explanation, not a + guess: the "overlapping the text area" half of R2-7b's report likely + reflects a genuine but minor (~7-9px) crowding between the scrollbar's + own anchor-reflowed position (`UiLayoutPolicy`, retail's raw-edge + system — verified this reflow mechanism itself works correctly, both + via `UiElement.ApplyAnchor`'s per-frame call and hand-computed against + the box's real 100x100 design-time template) and the box's authored + 26px right margin; this is within the authored geometry's own + tolerance and was NOT changed, since inventing a new pixel offset here + would be exactly the guessing this project's workflow forbids. Flagged + for the user's own re-check once the thumb is visible — it may no + longer be perceptible/relevant now that the box's own interior boundary + has moved too. +- **R2-8 RE-CHECKED, CONFIRMED NOT AUTHORED — Batch A's closure stands.** + Probed the installed EoR dat directly for `0x10000402`'s own `P0x17` + property (the SAME authored-caption mechanism `DatWidgetFactory` + already reads for every other element): absent on the default state + AND on every one of the field's named states. Batch A's GF-15 closure + already byte-verified retail's CODE never writes the prefill + (`CharGenState::RandomizeCharacter`, `gmCGSummaryPage::InitializePage`); + this batch closes the remaining unchecked half (the DAT-authored- + initial-text hypothesis) the same way — negative. No code change; + pinned as a live-DAT regression test + (`SummaryNameField_AuthorsNoP0x17OnAnyState`) so a future DAT re-extract + or a future guess can't silently reintroduce the wrong fix shape. + +Fixture + live-DAT tests only this round (no graphical client launch). +App suite 5334/3 (was 5321/3, +13, zero regressions): +1 `DatRichText` +wrap-width-with-margins test, +3 `UiText.ContentOffsetX` tests, +5 +`UiButton`/`DatWidgetFactory` tests (`WrapBlockLines` × 3, the value-box +confinement shape, the escape-normalize regression), +4 live-DAT tests +(the Heritage margin/first-line-origin pin, the Summary listbox scrollbar +wiring, the Aluvian how-to overflow proof, the name-field no-P0x17 pin). +Runtime 1735/0 unchanged. Full solution Release build green (0 errors). +Blast radius swept: `UiText.MarginLeft/Right/Top/Bottom` default to 0 and +are ADDITIVE with the pre-existing `Padding`, so every DAT-imported +multi-line text box that does NOT author properties `0x23`-`0x26` (the +overwhelming majority client-wide, including chat and the main game UI) +is byte-identical to before this fix — confirmed by the unchanged full +App suite pass count outside this batch's own new tests. **MILESTONE (2026-08-16, post-Batch-A build `1.0.2-cc.g`): the user completed the FIRST LIVE CHARACTER CREATE from acdream against local ACE — @@ -250,6 +410,14 @@ ISSUES.md; this doc is the six-page batch. acdream's existing (correct) behavior; the `[ Name` the user saw was most likely the field's own bracket-style empty-state chrome (GF-2/GF-12 textbox-decoration family), not a missing name-prefill feature. + **Re-checked at Batch E (R2-8) against the ONE hypothesis this note + left unchecked** — an authored initial-text string on the field's own + dat property `0x17`, the SAME mechanism `DatWidgetFactory` reads for + every other element's caption — and confirmed ABSENT on the field's + default state and every named state alike, live-DAT-probed against the + installed EoR dat. This closure now covers both the CODE half (this + paragraph) and the AUTHORED-DATA half (Batch E); no further hypothesis + remains unchecked. ## Presentation families (retail parity) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs index c90a3943..9b9cdb39 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -157,6 +157,24 @@ internal sealed class CharacterCreationSummaryPage : IDisposable if (_list is not null) _list.TemplateResolver = templateResolver; + // R2-7a (Campaign CC gate round 1 Batch E): the listbox's own linked + // scrollbar (dat property 0x72, ScrollId 0x10000401 — a SIBLING + // element, not a descendant of the listbox itself) was never wired + // to UiTemplateListBox.Scroll. Every other UiTemplateListBox owner in + // this codebase (SocialFriendsPageController, ConfigOptionsPageController, + // the Fellowship/Allegiance/Squelch pages) resolves + // ScrollbarElementId against the page root the SAME way — this page + // was the one holdout that never did. + if (_list is not null) + { + uint scrollbarElementId = _list.ScrollbarElementId; + if (scrollbarElementId != 0 + && UiElement.FindDescendant(pageRoot, scrollbarElementId) is UiScrollbar overviewScroll) + { + overviewScroll.Model = _list.Scroll; + } + } + _nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField; if (_nameField is not null) { diff --git a/src/AcDream.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs index c55f1476..60b10bc3 100644 --- a/src/AcDream.App/UI/Layout/DatRichText.cs +++ b/src/AcDream.App/UI/Layout/DatRichText.cs @@ -58,7 +58,15 @@ internal static class DatRichText ArgumentNullException.ThrowIfNull(segments); var lines = new List(); - float maximumWidth = MathF.Max(1f, target.Width - 2f * target.Padding); + // R2-1 (Campaign CC gate round 1 Batch E): the wrap width must shrink + // by the SAME left+right inset the draw path now applies (Padding + // plus the four retail margins, UiText.MarginLeft's own doc) — the + // Batch-C regression's second half: text wasn't just drawing at the + // wrong X, it was also wrapping to the FULL box width instead of the + // authored interior width, overflowing the visible right edge too. + float maximumWidth = MathF.Max( + 1f, + target.Width - (target.Padding + target.MarginLeft) - (target.Padding + target.MarginRight)); Func measure = target.DatFont is { } font ? font.MeasureWidth : static value => value.Length * 8f; diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index a2e4b1ea..4773e5dd 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -730,6 +730,14 @@ public static class DatWidgetFactory // ElementInfo.Outline's own default, so this is a no-op for the ~99% of text // elements that don't author it. Outline = info.Outline, + // R2-1 (Campaign CC gate round 1 Batch E): the four text-inset + // margins (dat properties 0x23-0x26 — MarginLeft's own doc + // comment on UiText). Default 0 — a no-op for every element that + // doesn't author them (only consumed by the multi-line path). + MarginLeft = info.MarginLeft, + MarginRight = info.MarginRight, + MarginTop = info.MarginTop, + MarginBottom = info.MarginBottom, }; t.ConfigureDatState(info); @@ -781,7 +789,12 @@ public static class DatWidgetFactory cachedWidth = t.Width; cachedFont = t.DatFont; cachedColor = t.DefaultColor; - float maximumWidth = Math.Max(1f, t.Width - 2f * t.Padding); + // R2-1: shrink by BOTH Padding and the four retail + // margins — see DatRichText.Compose's own comment on + // the same formula. + float maximumWidth = Math.Max( + 1f, + t.Width - (t.Padding + t.MarginLeft) - (t.Padding + t.MarginRight)); Func measure = t.DatFont is { } font ? font.MeasureWidth : static value => value.Length * 8f; @@ -810,7 +823,8 @@ public static class DatWidgetFactory || !state.Properties.Values.TryGetValue(0x17u, out var stateCaption) || stateCaption.Kind != UiPropertyKind.StringInfo) continue; - if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text) + if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue)) + is { Length: > 0 } text) (stateStrings ??= new Dictionary())[stateId] = text; } if (stateStrings is not null) @@ -1028,6 +1042,32 @@ public static class DatWidgetFactory || !info.TryGetEffectiveProperty(0x17u, out var property) || property.Kind != UiPropertyKind.StringInfo) return null; - return stringResolve(property.StringInfoValue); + string? resolved = stringResolve(property.StringInfoValue); + // R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL + // two-character escape "\n" (0x5C 0x6E), not a real line break — same + // fact BuildText's own authored-string path already normalized for + // (see that call site's own comment). Centralizing the normalize + // HERE, at the single choke point every P0x17 caption resolution in + // this file goes through (BuildText, BuildButton's own caption AND + // its lifted-child caption, BuildButton's coexisting ValueLabel, + // BuildCheckbox), closes the exact class of bug R2-2 found: a caption + // like the Profession credits button's own "Attribute\n Credits" + // rendered the literal backslash-n because BuildButton never + // normalized while BuildText did. BuildText's own subsequent + // Replace("\\n","\n") is now a harmless no-op (idempotent) — left in + // place rather than removed, since it costs nothing and documents the + // same fact locally. + return NormalizeEscapes(resolved); } + + /// + /// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize + /// applies, pulled out so the + /// per-STATE authored-caption loop below (which resolves a state's own + /// 0x17 directly, bypassing the effective-property resolution + /// wraps) gets the SAME normalize + /// instead of a second, easily-forgotten copy. + /// + private static string? NormalizeEscapes(string? raw) => + raw?.Replace("\\n", "\n").Replace("\r", string.Empty); } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 606e19e1..6f5edbd5 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -245,6 +245,27 @@ public sealed class ElementInfo /// public bool Invisible; + /// + /// Campaign CC gate round 1 Batch E (R2-1): the four independent + /// UIElement_Text text-inset margins, dat properties + /// 0x23/0x24/0x25/0x26 (IntegerBaseProperty + /// — UIElement_Text::OnSetAttribute @0x0046a640 cases + /// 0xf/0x10/0x11/0x12, i.e. + /// BaseProperty::GetPropertyName(arg2) - 0x14, writing + /// m_margL/m_margR/m_margU/m_margD). Ctor + /// default is 0 on all four (UIElement_Text::UIElement_Text + /// @0x004686d1-0046872d clears them before any authored value + /// applies). The chargen description boxes author margL=9, + /// margR=26, margU=15, margD=15 (live-DAT-probe-confirmed on + /// 0x100003C4/0x100003E0/0x10000409/ + /// 0x10000404) — this codebase never read these four + /// properties before this fix, so every DAT-imported UiText + /// drew flush against its own outer rect (Padding alone, + /// always 0 for DAT-built text) regardless of what the DAT actually + /// authored. + /// + public int MarginLeft, MarginRight, MarginTop, MarginBottom; + /// /// Resolves a property for a state using retail's DirectState-as-base rule. A /// named state's key overrides DirectState by presence, including false/zero. @@ -421,6 +442,15 @@ public static class ElementReader Outline = derived.Outline || base_.Outline, // OutlineColor: same "non-null derived wins" rule as FontColor. OutlineColor = derived.OutlineColor ?? base_.OutlineColor, + // R2-1: margins follow the same "non-zero derived wins" convention as + // FontDid/ZLevel above — a derived element that authors no margin + // property (0 is ApplyCanonicalLegacyProjection's own unset default, + // matching retail's ctor-cleared default too) inherits the base + // prototype's margin instead of silently zeroing it out. + MarginLeft = derived.MarginLeft != 0 ? derived.MarginLeft : base_.MarginLeft, + MarginRight = derived.MarginRight != 0 ? derived.MarginRight : base_.MarginRight, + MarginTop = derived.MarginTop != 0 ? derived.MarginTop : base_.MarginTop, + MarginBottom = derived.MarginBottom != 0 ? derived.MarginBottom : base_.MarginBottom, // DefaultStateName: derived wins if set; otherwise inherit the base's default. DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, // This helper merges one element snapshot only. LayoutImporter separately @@ -526,6 +556,20 @@ public static class ElementReader } } + // R2-1 (Campaign CC gate round 1 Batch E): the four text-inset margins + // (0x23 Left / 0x24 Right / 0x25 Up / 0x26 Down, IntegerBaseProperty — + // see MarginLeft's own doc comment for the decomp anchor). Absent + // properties leave the ElementInfo default of 0, matching retail's + // ctor-cleared default. + if (info.TryGetEffectiveInteger(0x23u, out int marginLeft)) + info.MarginLeft = marginLeft; + if (info.TryGetEffectiveInteger(0x24u, out int marginRight)) + info.MarginRight = marginRight; + if (info.TryGetEffectiveInteger(0x25u, out int marginTop)) + info.MarginTop = marginTop; + if (info.TryGetEffectiveInteger(0x26u, out int marginBottom)) + info.MarginBottom = marginBottom; + // Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the // Type-8 tab control's authored {button element, page element, isDefault} rows // (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 2cd171ab..e266710d 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -483,11 +483,23 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful float boxY = LabelBox?.Y ?? 0f; float boxWidth = LabelBox?.Width ?? Width; float boxHeight = LabelBox?.Height ?? Height; - float tx = LabelAlign == LabelAlignment.Left - ? boxX + LabelOffsetX - : boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default) - float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f; - ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor); + + // R2-2/R2-3 (Campaign CC gate round 1 Batch E): when this button + // ALSO carries a coexisting ValueLabel (GF-4a's own-caption + + // separate value slot — the Profession attribute/health/stamina/ + // mana credits buttons, the Skills credits button), the caption's + // own drawable region stops before the value's authored rect + // starts. LabelBox and ValueBox are mutually exclusive by + // construction (DatWidgetFactory.BuildButton only ever sets one + // or the other), so this never fights GF-11c's own LabelBox + // confinement above. Live-DAT-measured: "Available Skill Credits" + // is 193px wide in the Skills credits button's 231px-wide box + // whose value box starts at local x=116 — without this, the live + // credits number draws on top of the caption's own tail. + if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX) + boxWidth = MathF.Min(boxWidth, valueBoxX - boxX); + + DrawBlockLabel(ctx, label, lf, LabelColor, boxX, boxY, boxWidth, boxHeight, LabelAlign, LabelOffsetX); } if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf) @@ -517,6 +529,112 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } } + /// + /// R2-2 (Campaign CC gate round 1 Batch E): retail's UIElement_Button + /// IS a UIElement_Text (struct UIElement_Button : UIElement_Text, + /// acclient.h) — these captions author OneLine=false + /// (live-DAT-probe-confirmed on 0x100003e2-e5/0x100003f9), so a caption + /// that carries an authored newline (already normalized to a real + /// '\n' by 's shared + /// ResolveAuthoredString) OR simply doesn't fit + /// lays out as multiple stacked lines, using + /// the SAME word-wrap any other Type-12 + /// text box uses. A single line that already fits draws with byte- + /// identical geometry to the pre-fix unconditional one-line math (same + /// centered-block Y, same tx formula) — this is a strict superset, not a + /// behavior change, for every button whose caption was already short + /// enough to fit on one line. + /// + private void DrawBlockLabel( + UiRenderContext ctx, + string text, + UiDatFont font, + Vector4 color, + float boxX, + float boxY, + float boxWidth, + float boxHeight, + LabelAlignment align, + float leftOffset) + { + IReadOnlyList<(string Text, float X, float Y)> lines = WrapBlockLines( + text, font.MeasureWidth, font.LineHeight, + boxX, boxY, boxWidth, boxHeight, align, leftOffset); + + // A multi-line result clips to its own box — the button's normal + // draw has no ambient clip, and an oversized wrapped caption (e.g. + // the Skills credits button's own tight 28px height) should be cut + // off at the box edge rather than spill into whatever sits below the + // button, matching every other clipped Type-12 text box in this + // codebase (UiText.DrawText's own PushClip). Single-line captions — + // the overwhelming majority — never pay this cost. + bool clip = lines.Count > 1; + if (clip) + ctx.PushClip(boxX, boxY, boxWidth, boxHeight); + try + { + foreach ((string line, float tx, float ty) in lines) + ctx.DrawStringDat(font, line, tx, ty, color, Outline, OutlineColor); + } + finally + { + if (clip) + ctx.PopClip(); + } + } + + /// + /// Pure geometry half of — normalized + /// newline split + word-wrap () to + /// , then block-centered vertically within + /// . Pulled out as a static/pure method + /// (same shape as ) so the wrap/ + /// confinement math is unit-testable without a font atlas or draw + /// context — takes the place of + /// . + /// + internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines( + string text, + Func measureWidth, + float lineHeight, + float boxX, + float boxY, + float boxWidth, + float boxHeight, + LabelAlignment align, + float leftOffset) + { + float availableWidth = MathF.Max( + 1f, + boxWidth - (align == LabelAlignment.Left ? leftOffset : 0f)); + + var lines = new List(); + foreach (string paragraph in text.Split('\n')) + { + if (measureWidth(paragraph) <= availableWidth) + { + lines.Add(paragraph); + continue; + } + lines.AddRange(UiText.WrapWords(paragraph, measureWidth, availableWidth)); + } + + float totalHeight = lines.Count * lineHeight; + float startY = boxY + (boxHeight - totalHeight) * 0.5f; + + var result = new List<(string, float, float)>(lines.Count); + for (int i = 0; i < lines.Count; i++) + { + string line = lines[i]; + float tx = align == LabelAlignment.Left + ? boxX + leftOffset + : boxX + (boxWidth - measureWidth(line)) * 0.5f; + float ty = startY + i * lineHeight; + result.Add((line, tx, ty)); + } + return result; + } + private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect) { if (file == 0 || rect.Width <= 0 || rect.Height <= 0) diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 0f7ba89f..5bd2ab0e 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful /// public float Padding { get; set; } + /// + /// Campaign CC gate round 1 Batch E (R2-1): the four independent retail + /// text-inset margins (dat properties 0x23/0x24/0x25/ + /// 0x26's own doc + /// comment has the full decomp citation). Additive with + /// (every existing controller that sets + /// explicitly keeps behaving identically, since + /// these four default to 0 unless + /// seeds them from the DAT). Applied ONLY to the scrollable multi-line + /// path ( == false) — the chargen description boxes + /// that regressed in Batch C are all multi-line, and every authored + /// nonzero-margin box measured against the installed DAT so far is also + /// multi-line. The static Centered/RightAligned/OneLine single-line + /// paths are unchanged (still bare ) to keep this + /// fix's blast radius to the mechanism that actually regressed. + /// + public float MarginLeft { get; set; } + public float MarginRight { get; set; } + public float MarginTop { get; set; } + public float MarginBottom { get; set; } + /// Retail property 0x20. Independent of horizontal/vertical /// justification; false permits the normal multi-line layout path. public bool OneLine { get; set; } @@ -555,7 +576,10 @@ public sealed class UiText : UiElement, IUiDatStateful if (lines.Count == 0) return; float lh = _lastLineHeight; - float top = Padding, bottom = Height - Padding; + // R2-1: the multi-line viewport insets by BOTH Padding (the pre- + // existing uniform inset controllers already set) AND the four + // retail-authored margins (additive — see MarginTop's own doc). + float top = Padding + MarginTop, bottom = Height - Padding - MarginBottom; float innerH = bottom - top; float contentH = lines.Count * lh; @@ -731,11 +755,37 @@ public sealed class UiText : UiElement, IUiDatStateful float width = datFont is not null ? datFont.MeasureWidth(text) : bitmapFont?.MeasureWidth(text) ?? 0f; - if (Centered) - return Math.Max(Padding, (Width - width) * 0.5f); - if (RightAligned) - return Math.Max(Padding, Width - Padding - width); - return Padding; + return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned); + } + + /// + /// R2-1 (Campaign CC gate round 1 Batch E): pure per-line horizontal + /// placement for the MULTI-LINE (scrollable) path — the static + /// single-line Centered/RightAligned/OneLine branches in + /// have their own inline math and are + /// deliberately left on bare (see + /// 's own doc comment). Here, both + /// and the four retail margins inset the content + /// box a line lays out within. Pure/static so it is unit-testable + /// without a font or draw context — the same shape as + /// / above. + /// + public static float ContentOffsetX( + float elementWidth, + float padding, + float marginLeft, + float marginRight, + float lineWidth, + bool centered, + bool rightAligned) + { + float contentLeft = padding + marginLeft; + float contentRight = elementWidth - padding - marginRight; + if (centered) + return Math.Max(contentLeft, contentLeft + (contentRight - contentLeft - lineWidth) * 0.5f); + if (rightAligned) + return Math.Max(contentLeft, contentRight - lineWidth); + return contentLeft; } public override bool OnEvent(in UiEvent e) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 892439fe..b1ea5e84 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -164,6 +164,52 @@ public sealed class CharacterCreationLiveDatTests UiElement.FindDescendant(heritageRoot, 0x100003C4u)); } + /// + /// R2-1 (Campaign CC gate round 1 Batch E): the Heritage/Profession/ + /// Town/Summary description boxes author retail's four text-inset + /// margins (dat properties 0x23-0x26 — live-DAT-probe-confirmed + /// margL=9/margR=26/margU=15/margD=15 on all four, shared box + /// template) — this codebase never read them before this fix, so every + /// one of these boxes drew its first glyph flush against x=0 (Padding + /// alone, always 0 for DAT-built text), under the authored gold-frame's + /// own left border piece. Pins BOTH halves: the margins land on the + /// built (not just the raw ), + /// and computed with those margins + /// places the first line's origin at the authored interior (x=9), not + /// the box's outer edge (x=0). + /// + [InstalledDatFact] + public void HeritageDescription_MarginsMatchAuthoredInset_AndFirstLineOriginRespectsThem() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement heritageRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + UiText description = Assert.IsType( + UiElement.FindDescendant(heritageRoot, 0x100003C4u)); + + Assert.Equal(9f, description.MarginLeft); + Assert.Equal(26f, description.MarginRight); + Assert.Equal(15f, description.MarginTop); + Assert.Equal(15f, description.MarginBottom); + + // First glyph of a left-justified line: Padding (0, DAT-built text + // never sets it) + MarginLeft (9) = x=9, NOT x=0 — the exact + // regression the user's "rained Starting Skills"/"OW HUNTERS" + // reports describe (the leading 1-2 characters clipped under the + // frame's left border because text used to start at x=0). + float firstLineX = UiText.ContentOffsetX( + description.Width, description.Padding, + description.MarginLeft, description.MarginRight, + lineWidth: 40f, centered: false, rightAligned: false); + Assert.Equal(9f, firstLineX); + Assert.NotEqual(0f, firstLineX); + } + /// Seven template buttons, six attribute sliders (each with a /// lock button + scrollbar + value text), and the four derived /// displays (Profession page — @@ -854,6 +900,52 @@ public sealed class CharacterCreationLiveDatTests Assert.IsType(UiElement.FindDescendant(pairRow, 0x100002FDu)); } + /// + /// R2-8 (Campaign CC gate round 1 Batch E): re-checks the AUTHORED- + /// initial-text hypothesis the user's re-test raised for the + /// [ Name ] the retail screenshot shows — Batch A's GF-15 + /// closure already byte-verified retail's CODE never writes it + /// (CharGenState::RandomizeCharacter, + /// gmCGSummaryPage::InitializePage), but did not check whether + /// the field's own dat property 0x17 (the SAME authored-caption + /// mechanism DatWidgetFactory.BuildText/BuildField already + /// reads for every other element) carries a display-only placeholder. + /// It does not: the name field (0x10000402) authors NO 0x17 + /// on its default state or on ANY of its named states in the installed + /// EoR dat. Per this batch's own investigation contract ("if NOT + /// authored, STOP on this item"), this pins that negative result as a + /// durable regression check rather than leaving it as a one-off probe + /// finding — CONFIRMS Batch A's closure honestly, it does not change + /// acdream's behavior (the field stays genuinely empty, matching + /// retail's own code-empty field). + /// + [InstalledDatFact] + public void SummaryNameField_AuthorsNoP0x17OnAnyState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + ElementInfo nameField = Assert.IsType( + FindInfo(rootInfo, CharacterCreationSummaryPage.NameTextId)); + + Assert.False( + nameField.TryGetEffectiveProperty(0x17u, out _), + "the name field must not author a P0x17 caption on its effective " + + "default state — if this starts failing, the DAT now carries an " + + "authored placeholder and R2-8 should be revisited as a real fix."); + foreach (var (stateId, state) in nameField.States) + { + Assert.False( + state.Properties.Values.TryGetValue(0x17u, out var stateCaption) + && stateCaption.Kind == UiPropertyKind.StringInfo, + $"the name field's state 0x{stateId:X} ('{state.Name}') must not " + + "author a P0x17 caption either."); + } + } + /// /// CC5 re-review residual round, R3 (2026-08-16): MEASURES the /// installed global SkillTable's (portal.dat 0x0E000004) @@ -1315,6 +1407,122 @@ public sealed class CharacterCreationLiveDatTests dialogs.Dispose(); } + /// + /// R2-7a (Campaign CC gate round 1 Batch E): the Summary OVERVIEW + /// listbox (0x10000400) authors a linked scrollbar via dat + /// property 0x72 — live-DAT-probe-confirmed + /// ScrollbarElementId=0x10000401, a SIBLING element under the + /// Summary page root, not a descendant of the listbox itself. + /// 's constructor used to wire + /// only the how-to box's own scrollbar (Commit 3) and never resolved + /// this one, so the listbox never scrolled despite carrying more rows + /// than fit its 435px-tall view. Same linkage pattern every other + /// UiTemplateListBox owner in this codebase already uses + /// (SocialFriendsPageController, ConfigOptionsPageController, etc). + /// + [InstalledDatFact] + public void SummaryListbox_ScrollbarBuildsAndLinksToListboxScroll() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement summaryRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SummaryPageElementId)); + UiTemplateListBox list = Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId)); + Assert.Equal(CharacterCreationSummaryPage.ScrollId, list.ScrollbarElementId); + UiScrollbar overviewScroll = Assert.IsType( + UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId)); + + var host = new UiRoot(); + var dialogs = MakeDialogFactory(dats, host); + var bindings = new CharacterCreationRuntimeBindings( + () => null, + _ => default, _ => default, _ => default, (_, _) => default, (_, _) => default, + _ => default, _ => default, _ => default, _ => default, _ => default, () => { }); + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) => + LayoutImporter.Import( + dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root; + + CharacterCreationUiController? controller = + CharacterCreationUiController.CreateDetached( + host, screen, ResolveTemplate, dialogs, bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long")); + Assert.NotNull(controller); + controller!.AttachAndTick(); + + Assert.Same(list.Scroll, overviewScroll.Model); + + controller.Dispose(); + dialogs.Dispose(); + } + + /// + /// R2-7b (Campaign CC gate round 1 Batch E): the how-to box's scrollbar + /// THUMB only draws if (m.HasOverflow) + /// ('s own draw gate) — the reported "no + /// thumb" symptom traces to R2-1's bug, not an independent defect: with + /// the pre-fix wrap width (the box's raw Width, ignoring the authored + /// margL=9/margR=26 inset), the Aluvian how-to text (the LONGEST + /// composed variant — SummaryHowTo + the male name-suggestion list + + /// SummaryHowToEnd) wrapped to fewer/shorter lines than the correctly + /// inset width does. This pins the causal claim directly against the + /// real installed strings/font: composing with the CORRECT (margin- + /// inset) width produces content taller than the view, so + /// — which is exactly what + /// gates the thumb on — is true. + /// + [InstalledDatFact] + public void SummaryHowToText_Aluvian_WithCorrectMarginInsetWidth_Overflows() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + var strings = new DatStringResolver(dats); + const uint table = 0x23000002u; + string? howTo = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowTo")); + string? names = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_AluMaleNames")); + string? howToEnd = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowToEnd")); + Assert.NotNull(howTo); + Assert.NotNull(names); + Assert.NotNull(howToEnd); + string composed = howTo + names + howToEnd; + + // Real font metrics (0x40000009 — live-DAT-probe-confirmed FontDid + // on 0x10000404), no GL/texture needed for MeasureWidth. + Assert.True(dats.TryGet(0x40000009u, out var font) && font is not null); + var glyphs = new Dictionary(font!.CharDescs.Count); + foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd; + var datFont = new UiDatFont(0, 0, 0, 0, 0, 0, font.MaxCharHeight, font.BaselineOffset, glyphs); + + // Live-DAT-measured box geometry (0x10000404): 247x380, + // margL=9/margR=26/margU=15/margD=15. + var target = new UiText + { + Width = 247f, + Height = 380f, + DatFont = datFont, + MarginLeft = 9f, + MarginRight = 26f, + MarginTop = 15f, + MarginBottom = 15f, + }; + var segments = new[] { new DatRichText.Segment(composed, Vector4.One) }; + var lines = DatRichText.Compose(target, segments); + + float viewHeight = target.Height - target.Padding - target.MarginTop - target.Padding - target.MarginBottom; + float contentHeight = lines.Count * datFont.LineHeight; + + Assert.True( + contentHeight > viewHeight, + $"expected the correctly-inset composition ({lines.Count} lines, " + + $"{contentHeight}px) to overflow the {viewHeight}px view — if it " + + "doesn't, the how-to scrollbar's thumb has nothing to gate on " + + "regardless of the R2-1 margin fix"); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs index 4d94a819..2ae0193e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs @@ -105,6 +105,25 @@ public class DatRichTextTests Assert.Equal("second", lines[1].Text); } + [Fact] + public void Compose_WordWrapsToTheTargetWidth_MinusTheFourRetailMargins() + { + // R2-1 (Campaign CC gate round 1 Batch E): the wrap width must + // shrink by BOTH Padding and the four retail margins (properties + // 0x23-0x26 — MarginLeft's own doc comment on UiText), not just the + // element's raw Width. A 100px-wide box with margL=10/margR=10 + // leaves only 80px of usable width — one 10-char/8px-per-char word + // ("aaaaaaaaaa", 80px) must fit on one line, but appending an 11th + // 'a' (88px) must force a wrap. + UiText fits = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f }; + var fitsLines = DatRichText.Compose(fits, [new DatRichText.Segment("aaaaaaaaaa", White)]); + Assert.Single(fitsLines); + + UiText overflows = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f }; + var overflowLines = DatRichText.Compose(overflows, [new DatRichText.Segment("aaaaaaaaaaa", White)]); + Assert.True(overflowLines.Count > 1, "an 88px word in an 80px content width must wrap"); + } + [Fact] public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent() { diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index e21c5849..7ad930be 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -414,6 +414,142 @@ public class UiButtonTests Assert.Equal(externalColor, b.LabelColor); } + // ── R2-2/R2-3 (Campaign CC gate round 1 Batch E): WrapBlockLines ──── + + private static float BitmapMeasure(string text) => text.Length * 8f; + + /// + /// A single line that already fits its box draws with the SAME + /// centered-block geometry the pre-fix unconditional one-line math + /// produced — the fix is a strict superset for every already-working + /// button caption. + /// + [Fact] + public void WrapBlockLines_SingleLineThatFits_MatchesPriorOneLineGeometry() + { + var lines = UiButton.WrapBlockLines( + "Health", BitmapMeasure, lineHeight: 24f, + boxX: 0f, boxY: 0f, boxWidth: 150f, boxHeight: 50f, + UiButton.LabelAlignment.Left, leftOffset: 3f); + + Assert.Single(lines); + Assert.Equal("Health", lines[0].Text); + Assert.Equal(3f, lines[0].X); // boxX + leftOffset + Assert.Equal((50f - 24f) * 0.5f, lines[0].Y); // vertically centered, one line + } + + /// + /// R2-2: an authored newline (already normalized to a real '\n' by + /// DatWidgetFactory's ResolveAuthoredString) splits into stacked lines + /// even when EACH half individually fits the box — "Attribute\nCredits" + /// must become two lines, not one literal run. + /// + [Fact] + public void WrapBlockLines_EmbeddedNewline_ProducesTwoStackedLines() + { + var lines = UiButton.WrapBlockLines( + "Attribute\nCredits", BitmapMeasure, lineHeight: 24f, + boxX: 0f, boxY: 0f, boxWidth: 90f, boxHeight: 50f, + UiButton.LabelAlignment.Left, leftOffset: 3f); + + Assert.Equal(2, lines.Count); + Assert.Equal("Attribute", lines[0].Text); + Assert.Equal("Credits", lines[1].Text); + // Block-centered: total height 48 in a 50-tall box -> start Y = 1. + Assert.Equal(1f, lines[0].Y); + Assert.Equal(25f, lines[1].Y); // startY + 1*lineHeight + } + + /// + /// R2-3: a single-paragraph caption with NO authored newline still + /// word-wraps when it doesn't fit the available width — the exact + /// live-DAT shape of the Skills credits button's own "Available Skill + /// Credits" caption (measured 193px in a 231px-wide button whose value + /// box starts at local x=116, i.e. only ~113px of caption width is + /// actually available once R2-2/R2-3's confinement applies). + /// + [Fact] + public void WrapBlockLines_LongSingleParagraph_WordWrapsToFitAvailableWidth() + { + var lines = UiButton.WrapBlockLines( + "Available Skill Credits", BitmapMeasure, lineHeight: 24f, + boxX: 0f, boxY: 0f, boxWidth: 113f, boxHeight: 28f, + UiButton.LabelAlignment.Left, leftOffset: 3f); + + Assert.True(lines.Count > 1, "a 193px caption must wrap within a 110px available width"); + foreach (var line in lines) + Assert.True(BitmapMeasure(line.Text) <= 110f, $"line '{line.Text}' overflowed"); + } + + /// + /// R2-2/R2-3 confinement itself, exercised through OnDraw's own gate: + /// a button with BOTH Label and a coexisting ValueBox shrinks the + /// caption's OWN drawable width to stop before the value box starts — + /// this is what the two live-DAT overlap reports (R2-2 "24dits", R2-3 + /// "Credit0Credits") trace to: the caption used to draw across the + /// WHOLE button width regardless of where the value sat. + /// + [Fact] + public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox() + { + uint captionStringId = 333u; + var info = new ElementInfo { Type = 1, Width = 231, Height = 28 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0), + }; + info.StateMedia[""] = (0x06000001u, 1); + + var valueChild = new ElementInfo { Type = 12, X = 116, Y = 0, Width = 34, Height = 28 }; + info.Children.Add(valueChild); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null)); + + Assert.Equal("Available Skill Credits", button.Label); + Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox); + + // The caption's own available width for WrapBlockLines is bounded by + // ValueBox.X (116), NOT the button's full Width (231) — reproducing + // OnDraw's own confinement math here (private OnDraw isn't directly + // callable, so this pins the INPUT the fix computes for it). + float confinedWidth = System.MathF.Min(button.Width, button.ValueBox!.Value.X - 0f); + Assert.Equal(116f, confinedWidth); + Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button"); + } + + // ── R2-2 escape-normalize ──────────────────────────────────────────── + + /// + /// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way + /// BuildText's authored-string path always has — the DAT stores the + /// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession + /// credits button's own authored caption is exactly this shape. + /// + [Fact] + public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape() + { + uint stringId = 444u; + var info = new ElementInfo { Type = 1, Width = 150, Height = 50 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0), + }; + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + // The raw resolved string carries the LITERAL two characters + // '\' and 'n', matching what the installed DAT actually stores. + stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null)); + + Assert.Equal("Attribute\n Credits", button.Label); + } + private static UiButton ButtonWithStates(params string[] states) { var info = ButtonInfo(states); diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index 4ad1a966..1ddf4feb 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -278,6 +278,69 @@ public class UiTextTests Assert.Equal(9f, y); } + /// + /// R2-1 (Campaign CC gate round 1 Batch E): with zero margins, + /// ContentOffsetX is byte-identical to the pre-fix bare-Padding math — + /// every existing DAT-imported multi-line box (margins default 0 unless + /// DatWidgetFactory seeds them) is unaffected by this change. + /// + [Fact] + public void ContentOffsetX_ZeroMargins_MatchesBarePaddingMath() + { + float left = UiText.ContentOffsetX( + elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f, + lineWidth: 30f, centered: false, rightAligned: false); + Assert.Equal(4f, left); + + float centered = UiText.ContentOffsetX( + elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f, + lineWidth: 30f, centered: true, rightAligned: false); + Assert.Equal(Math.Max(4f, (200f - 30f) * 0.5f), centered); + + float right = UiText.ContentOffsetX( + elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f, + lineWidth: 30f, centered: false, rightAligned: true); + Assert.Equal(200f - 4f - 30f, right); + } + + /// + /// The exact live-DAT shape (Campaign CC gate round 1 Batch E, R2-1): + /// Heritage/Profession/Town/Summary description boxes author + /// margL=9/margR=26 — a left-justified line must start at x=9 (Padding + /// 0 + MarginLeft 9), not x=0. This is the regression: pre-fix, every + /// one of these boxes drew its first glyph at x=0, clipping under the + /// authored gold-frame's left border piece. + /// + [Fact] + public void ContentOffsetX_LeftJustified_HonorsAuthoredMarginLeft() + { + float x = UiText.ContentOffsetX( + elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f, + lineWidth: 100f, centered: false, rightAligned: false); + Assert.Equal(9f, x); + } + + /// + /// A right-aligned line must stop before MarginRight, not at the raw + /// element edge — the R2-1 fix's other half (the wrap width shrinks by + /// the same inset so text no longer overflows the visible right edge + /// either). + /// + [Fact] + public void ContentOffsetX_RightAligned_HonorsAuthoredMarginRight() + { + float x = UiText.ContentOffsetX( + elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f, + lineWidth: 50f, centered: false, rightAligned: false); + _ = x; // left case covered above + + float right = UiText.ContentOffsetX( + elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f, + lineWidth: 50f, centered: false, rightAligned: true); + // contentRight = 265 - 0 - 26 = 239; right-aligned x = 239 - 50 = 189. + Assert.Equal(189f, right); + } + [Fact] public void LineIntersectsViewport_PartialLineRemainsDrawable() { From 834c2547a9fa3fa8a62708cde2a438a07a116b58 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 14:16:14 +0200 Subject: [PATCH 123/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20G=20=E2=80=94=20real=20color=20wheel=20(DoC?= =?UTF-8?q?olorSpots/DoGradDisk=20color=20rendering)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-5: retail's gmCGAppearancePage::DoColorSpots/SetSelection/DoGradDisk paint the nine color swatches and the gradient disc with a real, computed representative color (PalSet-averaged for Hair/Nose+Mouth+ Skin/Headgear/Shirt/Trousers/Footwear at fixed sample indices 0xd0/0xb0/0x520; direct-Palette for Eyes at 0x103), not the static authored art acdream showed before this batch. Ports the full palette-to-RGB pipeline: a new pure Core resolver (ChargenSwatchColorResolver + IChargenPaletteColorSource) backed by a new ChargenAppearanceCatalog.TryGetColor reading real Palette dat objects, pinned against the installed EoR dat. CharacterCreationAppearancePage recomputes all nine swatches + the gradient disc's tint on every refresh (part/color/heritage change) and paints them through a new ChargenSwatchColorTile overlay child — a flat-color-fill approximation of retail's actual recolored-sprite blit, since neither UiButton (sealed) nor UiDatElement exposes a per-instance sprite tint today. Two STOPPED items remain outside this batch's file contract before the mechanism is visually live: (1) wiring PalSetSource/ClothingTableSource/ PaletteColorSource from CharacterCreationUiController.cs (mirrors the existing PreviewControl seam); (2) a small additive Tint property on UiButton/UiDatElement for a byte-true recolor instead of the flat fill. Also ports Nose/Mouth/Skin's single non-interactive representative swatch, beyond AP-216/AP-217's original six-part scope. Register AP-216/AP-217 rewritten (not retired — the two STOPPED items keep them open). Tests: 11 new Core, 6 new Content live-DAT, 8 new App-layer fixture. App suite 5321/3 -> 5329/3, Runtime 1735/0 unchanged, zero regressions. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 4 +- ...-08-16-campaign-cc-gate-round1-findings.md | 37 +- .../Layout/CharacterCreationAppearancePage.cs | 304 +++++++++++- .../UI/Layout/ChargenSwatchColorTile.cs | 69 +++ .../CharGen/ChargenAppearanceCatalog.cs | 37 +- .../CharGen/ChargenSwatchColor.cs | 46 ++ .../CharGen/ChargenSwatchColorResolver.cs | 197 ++++++++ ...rCreationAppearancePageSwatchColorTests.cs | 447 ++++++++++++++++++ .../ChargenAppearanceCatalogColorTests.cs | 234 +++++++++ .../ChargenSwatchColorResolverTests.cs | 212 +++++++++ 10 files changed, 1558 insertions(+), 29 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs create mode 100644 src/AcDream.Core/CharGen/ChargenSwatchColor.cs create mode 100644 src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs create mode 100644 tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogColorTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenSwatchColorResolverTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3f22d4e9..7641a8a8 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -396,8 +396,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED; RE-NARROWED 2026-08-16 at Batch C fix (GF-6/AP-218) — the "1-based ordinal" framing of item 2 is now STALE and replaced below.** What CLOSED at Batch B: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest. What CLOSED at Batch C: `SetStyleSpinLabel`'s 1-based-ordinal substitution is GONE — `RefreshSpinCaptions` now writes retail's own heritage-flavored STATIC caption (see AP-218, RETIRED). **Still open (RESTATED, not the same gap the ordinal covered):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) now show the SAME static caption regardless of which style is selected — retail's own per-choice visual feedback there is an ICON THUMBNAIL this port still doesn't render (no icon-texture pipeline is wired to ANY chargen widget); the live 3D preview is the player's only feedback for which style is currently active. The four clothing spins (headgear/shirt/trousers/footwear) show a real name via `ChargenGearOption.Name` and have no icon gap. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED Batch B; `RefreshSpinCaptions`, static-caption-only, icon gap still open) | An icon-texture pipeline for the four icon-only spins is new UI infrastructure this round's scope doesn't otherwise need; the static caption alone is retail-faithful for the TEXT half. | A pixel-level side-by-side against retail would show no icon thumbnail next to the four icon-only spins' caption (cosmetic gap only — the caption text itself is now byte-correct, and the live 3D preview still shows the actual selection). A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | -| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. **What CLOSED:** the "beyond the count" half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides (`Visible=false`) any swatch index at or past the current part's own `ColorCount`, the acdream equivalent of retail's blank blit. **Still open:** the "actual color" half — acdream's swatches still show only their authored (static) DAT art regardless of which color they individually represent; painting each swatch with its own computed color needs a PalSet/Palette-id -> RGB resolution pipeline no chargen page currently reads DAT palette pixels through at runtime (new UI infrastructure this batch judged disproportionate to add alongside its ~10 other fixes). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — hides beyond-count swatches, CLOSED; still sets no per-swatch color, OPEN) | The nine swatches already reach the correct SELECTION semantics AND the correct beyond-count visibility through existing `UiButton`/`UiElement.Visible` primitives; painting each swatch with a computed color needs a genuinely new palette-to-RGB render path this batch's scope didn't otherwise need. | A side-by-side against retail shows every VALID swatch drawing the SAME authored art regardless of which color it represents — a cosmetic gap only now (the beyond-count "stuck visibly on" gap that used to mislead a player about how many real choices existed is closed). | `gmCGAppearancePage::DoColorSpots @0x0047d850` | -| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes. **What CLOSED:** the Eyes-blank half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides the GradCircle when the current part is Eyes, the acdream equivalent of the blank "grad plug" blit. **Still open:** the gradient-graphic TINT half — acdream still never repaints the GradCircle with the current part's color; that composite (`Blit_Multiply` against `m_pGradGraphic`/`m_pGradPlug`) needs the SAME palette-to-RGB resolution pipeline AP-216's still-open half needs, so it stays open for the same reason. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls` now hides the GradCircle for Eyes, CLOSED; still never repaints it for any other part, OPEN) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint is genuinely new render infrastructure, same as AP-216's open half. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color for any part OTHER than Eyes (Eyes now correctly blanks) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | +| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1); PARTIALLY CLOSED 2026-08-16 at Batch C (the beyond-count half); REWRITTEN 2026-08-16 at the Campaign CC gate round 1 Batch G fix (R2-5) — the "actual color" half is now IMPLEMENTED AND TESTED, with two narrow STOPPED items outside this batch's file contract before it is visually live.** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed in `SetSelection @0x0047e260` into `m_tColorWheel[i].iRed/iGreen/iBlue` via `ClientCharGenState::GetColorFromPal @0x00563990`, a direct `Palette::get_color32`/`ARGB[index]` read at a fixed per-part sample index — `0xd0` Hair, `0xb0` Nose/Mouth/Skin, `0x103` Eyes, `0x520` Headgear/Shirt/Trousers/Footwear) and blits blank art for any swatch beyond the current part's real color count. **What CLOSED at Batch C:** the "beyond the count" half (`Visible=false` past `ColorCount`). **What Batch G ADDS:** the full palette-to-RGB pipeline — a new pure Core resolver (`ChargenSwatchColorResolver`: PalSet-averaged shape for Hair/Nose+Mouth+Skin/Headgear/Shirt/Trousers/Footwear, direct shape for Eyes, plus the clothing-swatch PalSet lookup through the CURRENTLY EQUIPPED garment's own ClothingTable) backed by a new `ChargenAppearanceCatalog.TryGetColor` (Content) reading real Palette dat objects, pinned against the installed EoR dat (`ChargenAppearanceCatalogColorTests` — e.g. Aluvian male Eye swatch 0 measures RGB(15,63,93), the shared skin PalSet measures a plausible RGB(182,148,118) flesh tone). `CharacterCreationAppearancePage` now computes all nine swatches' colors on every refresh (part change / color change / heritage change — `CharacterCreationAppearancePageSwatchColorTests`) and paints them via a new `ChargenSwatchColorTile` child element added on top of each swatch button. **Two STOPPED items remain, both outside this batch's file contract:** (1) the new `PalSetSource`/`ClothingTableSource`/`PaletteColorSource` late-bound properties (mirroring the existing `PreviewControl` seam) are never assigned by the composition root — until `CharacterCreationUiController.cs` wires a `ChargenAppearanceCatalog` instance into them (a 3-line addition, same shape as the existing `AppearancePreviewControl` wiring), the mechanism stays fully inert and every swatch shows ONLY its authored static art, exactly like before this batch (`UnwiredSources_LeaveEveryTileInvisible` pins this explicitly). (2) `ChargenSwatchColorTile` paints a FLAT color fill (`UiRenderContext.DrawFill`), not a genuine recolored sprite — neither `UiButton` (sealed) nor `UiDatElement` exposes a per-instance `Tint` on its existing `DrawSprite` calls (which DO already carry a `Vector4 tint` parameter the retained-UI shader multiplies against, matching retail's own `Blit_Multiply`); adding one is a small additive change to those two shared widget files this batch does not make. | `src/AcDream.Core/CharGen/ChargenSwatchColor.cs` + `ChargenSwatchColorResolver.cs` (new); `src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs` (`TryGetColor`, new); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`ComputeSwatchColors` + the swatch loop, now paints real color OR stays inert without the wiring); `src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs` (new) | Everything reachable inside this batch's file contract (Core resolver, Content palette read, the App page's own computation + rendering primitive) is fully implemented and tested; the two remaining gaps are BOTH shared-file edits (composition-root wiring; a widget Tint property) outside that contract, reported as STOPPED items rather than worked around. | Until the STOPPED composition-root wiring lands, a user still sees the pre-Batch-G static swatches (this batch changes nothing observable on its own). Once wired, every VALID swatch will show a flat-color patch at its own computed RGB rather than retail's recolored dot-shaped sprite — correct COLOR, approximated SHAPE, until the second STOPPED item (the widget Tint property) also lands. | `gmCGAppearancePage::DoColorSpots @0x0047d850`; `gmCGAppearancePage::SetSelection @0x0047e260`; `ClientCharGenState::GetColorFromPal @0x00563990`; `Palette::get_color32 @0x0053e050`; `CharGenState::StoreColorInformation @0x005c44d0`; `CharGenState::SetHeadgearStyle @0x005c5350` | +| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 (R3); PARTIALLY CLOSED 2026-08-16 at Batch C (the Eyes-blank half); REWRITTEN 2026-08-16 at the Campaign CC gate round 1 Batch G fix (R2-5) — the gradient-TINT half is now IMPLEMENTED AND TESTED, same two STOPPED items as AP-216 (they share the same underlying pipeline and rendering primitive).** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch has NO case for the GradCircle (`0x1000030e`) — it is not a click target. `DoGradDisk @0x0047da90` is PAINT-only, called from `SetColor`'s tail (`@0x0047de18`, AFTER `m_iCurColor` is updated) and from `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it tints the gradient graphic with `m_tColorWheel[m_iCurColor]`'s OWN color for every part except Eyes, or blits the blank "grad plug" for Eyes. **What CLOSED at Batch C:** the Eyes-blank half. **What Batch G ADDS:** the tint half, through the SAME `ChargenSwatchColorResolver`/`ChargenSwatchColorTile` machinery AP-216 now has — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` picks the color at the CURRENTLY SELECTED swatch index (index 0, unconditionally, for Nose/Mouth/Skin — retail hard-codes `eyeColor = 0` for those three cases in `SetSelection`) and paints the GradCircle's own tile with it; Eyes stays permanently untinted (`EyesPart_GradientDiscTileStaysBlank`), and the two STOPPED items from AP-216 (composition-root wiring; a genuine `UiButton`/`UiDatElement` sprite-tint property in place of the current flat-fill approximation) block this half from being visually live for the identical reason. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s GradCircle tint block; `_gradCircleTile`) | Same rationale as AP-216 — the full data pipeline is in place and tested; only the two shared-file STOPPED items (outside this batch's contract) remain before either half renders on screen. | Same STOPPED-item gating as AP-216: no observable change until the composition-root wiring lands; once wired, the disc shows a flat tint rather than retail's recolored gradient graphic until the widget Tint property also lands. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 83156c6f..6363e0c7 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -37,7 +37,42 @@ acdream GradCircle vs retail's color wheel). disc) where retail shows the gradient wheel + gold swatch dots that CHANGE per selected part — the user's gate PROMOTES AP-216/AP-217's remaining halves (real palette-color swatch rendering + gradient tint) - from partial-closed to must-port. + from partial-closed to must-port. **CODE-COMPLETE at Batch G (2026-08-16), + register AP-216/AP-217 rewritten (not retired — see their own rows):** + the retail mechanism (`gmCGAppearancePage::DoColorSpots @0x0047d850` / + `SetSelection @0x0047e260` / `DoGradDisk @0x0047da90`) is fully re-derived + and ported — a new pure Core resolver + (`AcDream.Core.CharGen.ChargenSwatchColorResolver`) computes each of the + nine swatches' representative RGB (PalSet-averaged for Hair/Nose+Mouth+ + Skin/Headgear/Shirt/Trousers/Footwear at retail's own fixed sample + indices `0xd0`/`0xb0`/`0x520`, direct-Palette for Eyes at `0x103`) backed + by a new `ChargenAppearanceCatalog.TryGetColor` reading real Palette dat + objects, pinned against the installed EoR dat + (`ChargenAppearanceCatalogColorTests` — e.g. Aluvian male's shared skin + PalSet measures a plausible flesh-tone RGB(182,148,118)). + `CharacterCreationAppearancePage` recomputes all nine swatches + the + gradient disc's tint on every refresh (part change / color change / + heritage change, `CharacterCreationAppearancePageSwatchColorTests`), and + paints them through a new `ChargenSwatchColorTile` overlay element. + **Two STOPPED items block this from being visually live**, both outside + Batch G's file contract: (1) the new `PalSetSource`/`ClothingTableSource`/ + `PaletteColorSource` late-bound seams (mirroring the existing + `PreviewControl` pattern) are never assigned by the composition root + (`CharacterCreationUiController.cs`) — until wired, the mechanism stays + fully inert, matching PRE-Batch-G behavior exactly; (2) the rendering + primitive is a flat-color-fill approximation of retail's actual + recolored-sprite blit — neither `UiButton` (sealed) nor `UiDatElement` + exposes a per-instance sprite `Tint`, though the retained-UI sprite + pipeline's `DrawSprite` already carries the `Vector4 tint` multiply + retail's own `Blit_Multiply` needs; adding that property is a small, + precisely-specified addition to those two shared widget files for the + lead to sequence. Nose/Mouth/Skin (retail's own non-interactive single + representative swatch, `SetSelection`'s hard-coded `var_1e0 = 1`) is ALSO + ported, beyond AP-216/AP-217's original six-part scope. Tests: 11 new + Core (`ChargenSwatchColorResolverTests`), 6 new Content live-DAT + (`ChargenAppearanceCatalogColorTests`), 8 new App-layer fixture + (`CharacterCreationAppearancePageSwatchColorTests`) — App suite + 5321/3 -> 5329/3, Runtime 1735/0 unchanged, zero regressions. - **R2-6: Town description text misaligned** — R2-1 family. - **R2-7: Summary — (a) text misaligned (R2-1); (b) the summary OVERVIEW listbox is missing its scrollbar; (c) the how-to box's scrollbar diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 37bd2929..813f242f 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.App.Rendering; using AcDream.Core.CharGen; using AcDream.Runtime; @@ -69,6 +70,24 @@ namespace AcDream.App.UI.Layout; /// trousers/footwear) DO carry a real /// and show it directly. /// +/// +/// +/// The real color wheel (Campaign CC gate round 1 Batch G, R2-5, +/// register AP-216/AP-217): retail's DoColorSpots @0x0047d850 / +/// DoGradDisk @0x0047da90 paint each swatch and the gradient disc +/// with an ACTUAL representative color sampled from the real DAT palette +/// data (AcDream.Core.CharGen.ChargenSwatchColorResolver ports the +/// computation — see its own doc for the two color-source shapes and the +/// clothing PalSet lookup). / +/// / are +/// late-bound composition seams (same pattern as ) +/// a DAT-backed catalog wires in after construction; the +/// children painted over each swatch/the gradient disc are this batch's +/// rendering primitive — see that class's own doc for why it is a flat +/// color fill (a documented approximation of retail's actual recolored- +/// sprite blit) and the STOPPED shared-file edit that would upgrade it to +/// a genuine texture tint. +/// /// internal sealed class CharacterCreationAppearancePage : IDisposable { @@ -176,6 +195,16 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiButton? _zoomOut; private readonly UiElement? _gradCircle; + /// R2-5: one flat-color tile per swatch, added as an EXTRA + /// child of the swatch it decorates (see 's + /// own doc) — null wherever the matching entry + /// itself is null (nothing to attach to). + private readonly ChargenSwatchColorTile?[] _swatchColorTiles = new ChargenSwatchColorTile?[SwatchIds.Length]; + + /// R2-5: the gradient disc's own tint tile, an extra child of + /// . + private readonly ChargenSwatchColorTile? _gradCircleTile; + private Choice _currentChoice = Choice.Face; private Part _currentPart = Part.Hair; private bool _eyesArrowsDisabled; @@ -186,6 +215,24 @@ internal sealed class CharacterCreationAppearancePage : IDisposable /// page cannot receive the real renderer at construction time. internal IChargenPreviewControl? PreviewControl { get; set; } + /// + /// R2-5 late-bound seams (same pattern as + /// above) for the real color-wheel mechanism — null (the default) + /// leaves every swatch/the gradient disc showing ONLY its authored + /// static art, i.e. this page's pre-Batch-G behavior, until a + /// composition root supplies a DAT-backed + /// AcDream.Content.CharGen.ChargenAppearanceCatalog (which + /// already implements all three interfaces) for these three + /// properties, mirroring how itself gets + /// wired in from outside this class. STOPPED (Batch G): that + /// assignment is a 3-line addition to + /// CharacterCreationUiController.cs, outside this batch's file + /// contract — see the batch's handoff notes. + /// + internal IChargenPalSetSource? PalSetSource { get; set; } + internal IChargenClothingTableSource? ClothingTableSource { get; set; } + internal IChargenPaletteColorSource? PaletteColorSource { get; set; } + /// The authored viewport (0x100003bb) — the composition /// root assigns its Renderer once the graphics backend exists, /// mirroring the paperdoll's own late viewport.Renderer = ... @@ -233,6 +280,18 @@ internal sealed class CharacterCreationAppearancePage : IDisposable int index = i; swatch.OnClick = () => SelectColor(index); _swatches[i] = swatch; + + // R2-5: an extra CHILD tile, sized to exactly cover the + // swatch's own face — see ChargenSwatchColorTile's own doc for + // why this is a flat fill rather than a recolored sprite, and + // for why ClickThrough there keeps this from ever swallowing + // the swatch's own click. + var tile = new ChargenSwatchColorTile + { + Left = 0f, Top = 0f, Width = swatch.Width, Height = swatch.Height, + }; + swatch.AddChild(tile); + _swatchColorTiles[i] = tile; } for (int i = 0; i < SwatchOverlayIds.Length; i++) @@ -243,6 +302,14 @@ internal sealed class CharacterCreationAppearancePage : IDisposable _shadeScroll.ScalarChanged = SetShadeFromScalar; _gradCircle = Find(pageRoot, GradCircleId); + if (_gradCircle is not null) + { + _gradCircleTile = new ChargenSwatchColorTile + { + Left = 0f, Top = 0f, Width = _gradCircle.Width, Height = _gradCircle.Height, + }; + _gradCircle.AddChild(_gradCircleTile); + } Viewport = Find(pageRoot, ViewportId); @@ -679,36 +746,71 @@ internal sealed class CharacterCreationAppearancePage : IDisposable overlay.Visible = colorSlot is not null && currentColor == (uint)i; } - // AP-216 (Campaign CC gate round 1 Batch C, PARTIAL): retail's - // DoColorSpots @0x0047d850 blits ACTUAL-color art for each valid - // swatch and BLANK art for any swatch beyond the current part's - // real color count. Painting each swatch with its own represented - // color needs a PalSet/Palette-id -> RGB resolution pipeline this - // batch does not add (no chargen page currently reads DAT palette - // pixels at runtime) — register AP-216 stays open for that half. - // This ships the cheap, fully-evidenced half: hiding a swatch a - // part's color list doesn't actually have (closest faithful - // rendering the existing pipeline supports — Visible=false is the - // acdream equivalent of "blit nothing"). - int colorCount = colorSlot is not null - && TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender) - ? ColorCount(_currentPart, swatchGender) - : 0; + // AP-216 (Campaign CC gate round 1 Batch C PARTIAL -> Batch G, + // R2-5, FULL): retail's DoColorSpots @0x0047d850 blits ACTUAL-color + // art for each valid swatch and BLANK art for any swatch beyond the + // current part's real color count. The "beyond count" half shipped + // at Batch C (hiding a swatch the part's color list doesn't have); + // this batch adds the "actual color" half via + // ChargenSwatchColorResolver (see this page's own class doc). + // + // displayCount diverges from the interactive colorSlot/colorCount + // pairing for exactly one family: Nose/Mouth/Skin (colorSlot == + // null, per ColorSlotFor's own doc) still get ONE representative + // swatch in retail — SetSelection's Nose/Mouth/Skin cases each hard- + // code var_1e0 = 1 (@0x0047e456/0x0047e4b7/0x0047e510) even though + // no ListenToElementMessage case ever makes that swatch clickable + // (SetColor's switch has no case for those three parts either). + bool swatchGenderResolved = TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender); + int colorCount = colorSlot is not null && swatchGenderResolved + ? ColorCount(_currentPart, swatchGender!) + : 0; + int displayCount = colorSlot is not null ? colorCount : 1; + + ChargenSwatchRgb?[] swatchColors = swatchGenderResolved + ? ComputeSwatchColors(swatchGender!, snapshot.Appearance) + : new ChargenSwatchRgb?[SwatchIds.Length]; + for (int i = 0; i < _swatches.Length; i++) { - if (_swatches[i] is { } swatch) - swatch.Visible = colorSlot is not null && i < colorCount; + if (_swatches[i] is not { } swatch) + continue; + bool visible = i < displayCount; + swatch.Visible = visible; + if (_swatchColorTiles[i] is { } tile) + { + ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null; + tile.Color = rgb is { } c ? ToTintColor(c) : null; + tile.Visible = rgb is not null; + } } - // AP-217 (PARTIAL): gmCGAppearancePage::DoGradDisk @0x0047da90 - // blits the blank "grad plug" for Eyes (DoGradDisk(this, 1), - // called from SetSelection @0x0047e85d) and a gradient graphic - // TINTED with the current part's color otherwise — the tinted - // repaint needs the same palette-to-RGB pipeline AP-216's open - // half needs, so it stays open too. This ships the evidenced - // Eyes-blank half only. + // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL): + // gmCGAppearancePage::DoGradDisk @0x0047da90 blits the blank "grad + // plug" for Eyes (DoGradDisk(this, 1), called from SetSelection + // @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY + // SELECTED swatch's own color otherwise (SetColor @0x0047dd50's + // tail, DoGradDisk(this, 0) after m_iCurColor is already updated — + // @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0 + // (SetSelection hard-codes eyeColor = 0 for those three cases, + // matching displayCount's own reasoning above). if (_gradCircle is not null) - _gradCircle.Visible = _currentPart != Part.Eyes; + { + bool isEyes = _currentPart == Part.Eyes; + _gradCircle.Visible = !isEyes; + if (_gradCircleTile is { } gradTile) + { + int gradIndex = isEyes + ? -1 + : colorSlot is null + ? 0 + : (int)ColorCurrent(_currentPart, snapshot.Appearance); + ChargenSwatchRgb? gradColor = + gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null; + gradTile.Color = gradColor is { } gc ? ToTintColor(gc) : null; + gradTile.Visible = !isEyes && gradColor is not null; + } + } ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); if (_shadeScroll is null) @@ -727,6 +829,152 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } } + // ── Real swatch/gradient colors (R2-5) ────────────────────────────── + + private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length]; + + /// + /// Computes one representative per + /// swatch slot (0..8, matching 's own order) for + /// , or an all-null array wherever the + /// palette-resolution seams (/ + /// /) + /// aren't wired yet — see this page's own class doc + + /// 's doc + /// for the retail mechanism each branch below ports. + /// + private ChargenSwatchRgb?[] ComputeSwatchColors( + ChargenGenderOptions gender, RuntimeCharacterCreationAppearance appearance) + { + if (PalSetSource is not { } palSets || PaletteColorSource is not { } colors) + return EmptySwatchColors; + + var result = new ChargenSwatchRgb?[SwatchIds.Length]; + switch (_currentPart) + { + case Part.Hair: + FillPalSetFamily(result, gender.HairColors, palSets, colors, ChargenSwatchColorResolver.HairSampleIndex); + break; + case Part.Eyes: + FillDirectFamily(result, gender.EyeColors, colors, ChargenSwatchColorResolver.EyeSampleIndex); + break; + case Part.Nose: + case Part.Mouth: + case Part.Skin: + // Retail: ONE representative swatch sourced from the + // single skin PalSet (SetSelection's Nose/Mouth/Skin cases, + // @0x0047e488/0x0047e4e9/0x0047e542 — all three set + // __return = 0xb0 against the same skinPalSetID DBObj get). + if (ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, gender.SkinPalSetId, + ChargenSwatchColorResolver.SkinFamilySampleIndex, out ChargenSwatchRgb skin)) + { + result[0] = skin; + } + break; + case Part.Headgear: + FillClothingFamily(result, gender, gender.Headgears, appearance.HeadgearStyle, palSets, colors); + break; + case Part.Shirt: + FillClothingFamily(result, gender, gender.Shirts, appearance.ShirtStyle, palSets, colors); + break; + case Part.Trousers: + FillClothingFamily(result, gender, gender.Pants, appearance.TrousersStyle, palSets, colors); + break; + case Part.Footwear: + FillClothingFamily(result, gender, gender.Footwear, appearance.FootwearStyle, palSets, colors); + break; + } + return result; + } + + /// Hair's shape: one PalSet id per swatch index, straight off + /// (already the exact + /// list + /// indexes for the SAME selection when composing the 3D preview). + private static void FillPalSetFamily( + ChargenSwatchRgb?[] result, + IReadOnlyList palSetIds, + IChargenPalSetSource palSets, + IChargenPaletteColorSource colors, + int sampleIndex) + { + int count = Math.Min(result.Length, palSetIds.Count); + for (int i = 0; i < count; i++) + { + if (ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, palSetIds[i], sampleIndex, out ChargenSwatchRgb c)) + { + result[i] = c; + } + } + } + + /// Eyes' shape: one Palette id per swatch index DIRECTLY off + /// — no PalSet + /// indirection, no averaging (see 's own + /// doc for why Eyes is the one exception). + private static void FillDirectFamily( + ChargenSwatchRgb?[] result, + IReadOnlyList paletteIds, + IChargenPaletteColorSource colors, + int sampleIndex) + { + int count = Math.Min(result.Length, paletteIds.Count); + for (int i = 0; i < count; i++) + { + if (ChargenSwatchColorResolver.TryGetDirectColor(colors, paletteIds[i], sampleIndex, out ChargenSwatchRgb c)) + result[i] = c; + } + } + + /// + /// Headgear/Shirt/Trousers/Footwear's shape: every swatch index shares + /// the SAME template-id + /// list (register AP-208), resolved against the CURRENTLY EQUIPPED + /// garment's own ClothingTable — see + /// 's + /// own doc for why a direct by-id lookup reproduces retail's + /// StoreColorInformation result without needing its own array- + /// building order. + /// + private void FillClothingFamily( + ChargenSwatchRgb?[] result, + ChargenGenderOptions gender, + IReadOnlyList gearOptions, + uint styleIndex, + IChargenPalSetSource palSets, + IChargenPaletteColorSource colors) + { + if (ClothingTableSource is not { } clothingTables) + return; + // Retail: an Unset ("no garment") style leaves numHeadgearColors + // (etc) at its CharGenState::SetHeadgearStyle @0x005c5350 reset + // value of 0 — no garment equipped means no dye choices to show. + if (styleIndex == Unset || styleIndex >= (uint)gearOptions.Count) + return; + + uint clothingTableId = gearOptions[(int)styleIndex].ClothingTableId; + IReadOnlyList clothingColors = gender.ClothingColors; + int count = Math.Min(result.Length, clothingColors.Count); + for (int i = 0; i < count; i++) + { + if (!ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId( + clothingTables, clothingTableId, clothingColors[i], out uint palSetId)) + { + continue; + } + if (ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb c)) + { + result[i] = c; + } + } + } + + private static Vector4 ToTintColor(ChargenSwatchRgb rgb) => + new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f); + // ── Spin captions ──────────────────────────────────────────────── /// @@ -961,5 +1209,11 @@ internal sealed class CharacterCreationAppearancePage : IDisposable // PreviewControl is owned by the composition root (disposed with // the leased ChargenPreviewRenderer) — just drop the reference. PreviewControl = null; + // R2-5: same ownership shape as PreviewControl above — these are + // borrowed references into a DAT-backed catalog the composition + // root owns, not this page's own resources. + PalSetSource = null; + ClothingTableSource = null; + PaletteColorSource = null; } } diff --git a/src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs b/src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs new file mode 100644 index 00000000..069ad825 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs @@ -0,0 +1,69 @@ +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): paints +/// one flat-fill patch of a computed +/// on top of whatever element it is attached to as a child — the +/// Appearance page's real-color rendering primitive for the nine color +/// swatches and the gradient disc. +/// +/// +/// Why a flat fill, not a recolored sprite (documented approximation): +/// retail's own mechanism (gmCGAppearancePage::DoColorSpots @0x0047d850 +/// / DoGradDisk @0x0047da90) blits an authored "spot"/gradient +/// graphic and RECOLORS it in place +/// (SurfaceWindow::ReplaceColor / BlitAndColor(..., +/// Blit_Multiply, color)) — a genuine multiplicative texture tint. The +/// retained-UI sprite pipeline this codebase already has +/// () DOES carry a per-draw +/// Vector4 tint parameter that could reproduce that exact multiply +/// blend, but neither (the nine swatches' own type, +/// sealed) nor (the gradient disc's own type) +/// exposes a per-instance tint hook on their EXISTING sprite draw calls — +/// adding one is a small, precisely-scoped, additive change to those two +/// shared widget files, outside this batch's file contract (reported as a +/// STOPPED item; see the batch's own commit message / handoff notes for the +/// exact diff). Rather than leave the swatches/wheel colorless pending that +/// follow-up, this class achieves the same OBSERVABLE result — "this +/// swatch/wheel visibly reflects the real computed color" — the cheapest +/// way the CURRENT public primitives allow: +/// is a plain solid-color quad, so the tile reads as a flat color patch +/// rather than a recolored dot/gradient graphic. It is added as an extra +/// CHILD of the swatch/disc it decorates (never replacing or subclassing +/// either sealed/shared type), so it draws strictly ON TOP +/// (: children paint after their +/// parent's own OnDraw) without disturbing the underlying element's +/// own state machine, media, or click handling at all. +/// +/// +/// +/// defaults to false on the +/// base class, so this MUST be set true by the constructor here (not left +/// to a caller to remember) — walks +/// children BEFORE testing the parent, and an opaque, click-absorbing tile +/// sitting on top of a swatch button would silently eat every click meant +/// for it. +/// +/// +internal sealed class ChargenSwatchColorTile : UiElement +{ + public ChargenSwatchColorTile() + { + ClickThrough = true; + Visible = false; + } + + /// The color to paint, or null to draw nothing this frame + /// ( is the authoritative on/off switch — callers + /// should set both together, matching every other swatch-visibility + /// site in CharacterCreationAppearancePage). + public Vector4? Color { get; set; } + + protected override void OnDraw(UiRenderContext ctx) + { + if (Color is { } c && Width > 0f && Height > 0f) + ctx.DrawFill(0f, 0f, Width, Height, c); + } +} diff --git a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs index 0142d4a9..5d521672 100644 --- a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs +++ b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using AcDream.Core.CharGen; using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable; +using DatPalette = DatReaderWriter.DBObjs.Palette; using DatPalSet = DatReaderWriter.DBObjs.PalSet; using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect; using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette; @@ -32,12 +33,24 @@ namespace AcDream.Content.CharGen; /// protect the CACHE from concurrent mutation — they do nothing for the /// underlying DatCollection read the cache miss triggers. /// +/// +/// +/// (Campaign CC gate round 1 +/// Batch G, R2-5): the real color-wheel/swatch mechanism +/// (ChargenSwatchColorResolver) needs one more DAT read this class +/// didn't previously do — a raw Palette dat object's (0x04......) own color +/// table, retail's Palette::get_color32 equivalent. Same lazy-cache +/// shape as /, +/// same DAT-lock obligation on every call site. +/// /// -public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource +public sealed class ChargenAppearanceCatalog : + IChargenPalSetSource, IChargenClothingTableSource, IChargenPaletteColorSource { private readonly IDatReaderWriter _dats; private readonly ConcurrentDictionary _palSets = new(); private readonly ConcurrentDictionary _clothingTables = new(); + private readonly ConcurrentDictionary _palettes = new(); public ChargenAppearanceCatalog(IDatReaderWriter dats) { @@ -50,6 +63,28 @@ public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClo public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => _clothingTables.GetOrAdd(clothingTableId, LoadClothingTable); + /// + /// Retail's ClientCharGenState::GetColorFromPal @0x00563990: load + /// the Palette dat object and read its color table at a fixed index — + /// direct ARGB[index], no averaging, no shade indirection. Unlike + /// retail's own unchecked array read, this bounds-checks + /// against the loaded palette's actual color + /// count and returns false rather than reading out of range (see + /// 's own doc for + /// why that divergence is deliberate). + /// + public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) + { + color = default; + DatPalette? palette = _palettes.GetOrAdd(paletteId, id => _dats.Get(id)); + if (palette is null || index < 0 || index >= palette.Colors.Count) + return false; + + DatReaderWriter.Types.ColorARGB c = palette.Colors[index]; + color = new ChargenSwatchRgb(c.Red, c.Green, c.Blue); + return true; + } + private ChargenPalSet? LoadPalSet(uint id) { DatPalSet? palSet = _dats.Get(id); diff --git a/src/AcDream.Core/CharGen/ChargenSwatchColor.cs b/src/AcDream.Core/CharGen/ChargenSwatchColor.cs new file mode 100644 index 00000000..4f9d6586 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSwatchColor.cs @@ -0,0 +1,46 @@ +namespace AcDream.Core.CharGen; + +/// +/// A resolved representative swatch color — the RGB byte triple retail's +/// Palette::get_color32 @0x0053e050 (a direct ARGB[index] +/// read, no bounds check on the real client) yields for one fixed sample +/// index into a Palette dat object's (0x04......) color table. Alpha is +/// deliberately omitted: retail's swatch/gradient recolor path +/// (SurfaceWindow::ReplaceColor / +/// SurfaceWindow::BlitAndColor(..., Blit_Multiply, ...)) only ever +/// reads R/G/B out of the sampled color — gmCGAppearancePage's +/// m_tColorWheel entries carry iRed/iGreen/iBlue +/// fields and no iAlpha at all (DoColorSpots @0x0047d850, +/// DoGradDisk @0x0047da90). +/// +public readonly record struct ChargenSwatchRgb(byte R, byte G, byte B); + +/// +/// Resolves one Palette dat object (0x04......) to a representative color +/// at a fixed sample index — retail's +/// ClientCharGenState::GetColorFromPal @0x00563990 +/// (DBObj::Get(QualifiedDataID(id, PALETTE_TYPE=0xa)) then +/// Palette::get_color32(index), i.e. a direct, unchecked +/// ARGB[index] read). The production implementation +/// (AcDream.Content.CharGen.ChargenAppearanceCatalog) reads and +/// caches the real dat object, matching 's +/// established Core/Content split (interface in Core, Chorizite-backed +/// implementation in Content); unit tests supply a hand-built fake so this +/// interface's only consumer, , +/// stays free of any Chorizite dependency. +/// +public interface IChargenPaletteColorSource +{ + /// + /// Returns false when the Palette dat object itself doesn't resolve, OR + /// when falls outside its color table — + /// retail's own Palette::get_color32 has NO bounds check (a + /// genuinely unchecked ARGB[index] read), so this is a + /// deliberate defensive divergence: acdream cannot reproduce retail's + /// undefined-behavior read as safe managed code, and treats an + /// out-of-range sample the same as a missing palette (no representative + /// color, caller skips the contribution) rather than throwing or + /// fabricating a value. + /// + bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color); +} diff --git a/src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs b/src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs new file mode 100644 index 00000000..f958c9ef --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs @@ -0,0 +1,197 @@ +namespace AcDream.Core.CharGen; + +/// +/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217's +/// remaining halves): ports the color-computation half of +/// gmCGAppearancePage::SetSelection @0x0047e260 — the per-swatch +/// representative RGB retail stores into m_tColorWheel[i].iRed/iGreen/ +/// iBlue before DoColorSpots @0x0047d850 paints it and +/// DoGradDisk @0x0047da90 tints the gradient disc with the CURRENTLY +/// selected swatch's own entry. +/// +/// +/// Two distinct color-source shapes, both decomp-traced: +/// +/// +/// +/// PalSet-averaged (Hair / Nose+Mouth+Skin / Headgear / Shirt / +/// Trousers / Footwear). Retail resolves ONE PalSet id per swatch +/// index, then — for EVERY Palette id inside that PalSet (its num_pals +/// sub-palettes/shades) — samples a FIXED index via +/// GetColorFromPal and averages the R/G/B channels +/// (@0x0047e759-0x0047e80f, the shared loop every non-Eyes case +/// jumps into at label_47e74b). The averaging is intentional: the +/// swatch shows one representative hue for a color CHOICE that actually +/// spans several shade variants, not any single shade. +/// +/// +/// Direct (Eyes only). Retail uses the raw entry from +/// ChargenGenderOptions.EyeColors directly as a Palette id — no +/// PalSet indirection, no averaging, one GetColorFromPal call per +/// swatch (@0x0047e3bf-0x0047e40f), matching +/// 's own doc for why Eyes is the one +/// exception to the PalSet convention everywhere else in this campaign. +/// +/// +/// +/// +/// Clothing's PalSet id source (Headgear/Shirt/Trousers/Footwear) is +/// itself retail's own second-order lookup: CharGenState::SetHeadgearStyle +/// @0x005c5350 (and its Shirt/Trousers/Footwear siblings, +/// @0x005c5470/0x005c5590/0x005c56b0) call +/// StoreColorInformation @0x005c44d0 against the NEWLY SELECTED +/// garment's own ClothingTable (DBObj::Get(clothingTableId, 0x19)) +/// every time the style changes, walking that table's own +/// CloPaletteTemplate hash table and recording — for every template +/// id that ALSO appears in the gender's shared +/// list — that template's +/// FIRST sub-palette choice's PalSet id +/// (headgearPalSetIDs[]/shirtPalSetIDs[]/etc, offset +0x10 +/// off the copied CloPaletteTemplate, a decompiler-elided field read +/// cross-checked against this codebase's own +/// ChargenClothingSubPaletteChoice.PalSetId — the first +/// Choices entry of the SAME projected shape). +/// reproduces the OBSERVABLE +/// result (which PalSet a given +/// index represents for the currently equipped garment) via a direct +/// dictionary lookup by template id rather than replicating retail's own +/// array-building traversal — a hash-table walk's OWN internal bucket order +/// is an implementation detail of retail's cache, not part of the +/// observable behavior, and a by-id lookup is provably order-independent. +/// This keeps the swatch index space IDENTICAL to what +/// ChargenAppearanceFactory.ComposeClothingSlot already treats as +/// canonical (gender.ClothingColors[(int)colorIndex], the SAME +/// index space RuntimeCharacterCreationAppearance persists and the +/// 3D preview already renders correctly from, proven across this +/// campaign's own installed-DAT and live two-client gates) — deliberately +/// NOT re-deriving a second, potentially-divergent index space from +/// StoreColorInformation's own cache-building order. +/// +/// +public static class ChargenSwatchColorResolver +{ + /// Hair's fixed sample index (gmCGAppearancePage::SetSelection + /// case ECG_PARTS_HAIR, __return = 0xd0 @0x0047e388). + public const int HairSampleIndex = 0xd0; + + /// Nose/Mouth/Skin's shared fixed sample index — all three route + /// to the SAME single-entry PalSet id (ChargenGenderOptions.SkinPalSetId) + /// with __return = 0xb0 (Nose @0x0047e488, Mouth @0x0047e4e9, Skin + /// @0x0047e542). + public const int SkinFamilySampleIndex = 0xb0; + + /// Eyes' fixed sample index, used DIRECTLY against + /// ChargenGenderOptions.EyeColors[i] with no PalSet indirection + /// (case ECG_PARTS_EYES, GetColorFromPal(..., 0x103) + /// @0x0047e3e2). + public const int EyeSampleIndex = 0x103; + + /// The shared clothing sample index — Headgear/Shirt/Trousers/ + /// Footwear all set __return = 0x520 before falling into the + /// shared averaging loop (@0x0047e5be/0x0047e62b/0x0047e6ab/0x0047e733). + public const int ClothingSampleIndex = 0x520; + + /// + /// PalSet-averaged representative color (Hair / Nose+Mouth+Skin / + /// Headgear / Shirt / Trousers / Footwear) — retail's shared + /// label_47e74b loop: resolve the PalSet, sample every one of its + /// Palette ids at , average the R/G/B + /// channels. A PalSet that resolves but carries zero Palette ids + /// reproduces retail's own explicit zero-init with no averaging + /// division (@0x0047e790 zeroes iRed/iGreen/iBlue + /// unconditionally before the num_pals > 0 guard) — returns + /// true with a BLACK color, not false, matching retail's actual output + /// for that shape. Returns false only when the PalSet id itself doesn't + /// resolve at all (retail's outer if (__return_8 != 0) miss, + /// which leaves that swatch's m_tColorWheel entry untouched from + /// whatever it held before — the closest acdream equivalent is "no + /// color to paint this swatch with"). + /// + public static bool TryGetPalSetAverageColor( + IChargenPalSetSource palSets, + IChargenPaletteColorSource colors, + uint palSetId, + int sampleIndex, + out ChargenSwatchRgb color) + { + ArgumentNullException.ThrowIfNull(palSets); + ArgumentNullException.ThrowIfNull(colors); + + color = default; + ChargenPalSet? palSet = palSets.TryGetPalSet(palSetId); + if (palSet is null) + return false; + + if (palSet.PaletteIds.Count == 0) + return true; // retail: explicit zero-init, no division — black. + + int sumR = 0, sumG = 0, sumB = 0; + foreach (uint paletteId in palSet.PaletteIds) + { + // A per-entry miss contributes (0,0,0) to the running sum — + // retail's own loop (@0x0047e7a5-0x0047e7dc) accumulates + // unconditionally and always divides by the FULL num_pals + // afterward; GetColorFromPal's own miss path + // (@0x005639b5) returns 0 rather than skipping the entry. + if (colors.TryGetColor(paletteId, sampleIndex, out ChargenSwatchRgb c)) + { + sumR += c.R; + sumG += c.G; + sumB += c.B; + } + } + + int count = palSet.PaletteIds.Count; + color = new ChargenSwatchRgb((byte)(sumR / count), (byte)(sumG / count), (byte)(sumB / count)); + return true; + } + + /// + /// Direct representative color (Eyes only) — one + /// call against + /// with no PalSet indirection and no + /// averaging, matching retail's ECG_PARTS_EYES case exactly. + /// + public static bool TryGetDirectColor( + IChargenPaletteColorSource colors, + uint paletteId, + int sampleIndex, + out ChargenSwatchRgb color) + { + ArgumentNullException.ThrowIfNull(colors); + return colors.TryGetColor(paletteId, sampleIndex, out color); + } + + /// + /// Resolves the PalSet id a clothing swatch index represents for the + /// CURRENTLY EQUIPPED garment — see this class's own doc for why a + /// direct by-id lookup reproduces retail's observable + /// StoreColorInformation result without replicating its own + /// cache-building traversal order. Returns false when the garment's + /// ClothingTable doesn't resolve, has no palette template for + /// , or that template carries no + /// sub-palette choices at all (an authored garment with a dye slot but + /// literally zero dye options) — every case retail's own miss paths + /// treat as "this swatch has no color." + /// + public static bool TryGetClothingSwatchPalSetId( + IChargenClothingTableSource clothingTables, + uint clothingTableId, + uint paletteTemplateId, + out uint palSetId) + { + ArgumentNullException.ThrowIfNull(clothingTables); + + palSetId = 0; + ChargenClothingTable? table = clothingTables.TryGetClothingTable(clothingTableId); + if (table is null) + return false; + if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template)) + return false; + if (template.Choices.Count == 0) + return false; + + palSetId = template.Choices[0].PalSetId; + return true; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs new file mode 100644 index 00000000..b3392e80 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs @@ -0,0 +1,447 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): fixture +/// tests for 's real swatch/ +/// gradient-disc color computation — a hand-built layout + fake DAT sources +/// (no installed dat), so recompute triggers can be asserted deterministically. +/// Real installed-DAT color values are pinned separately in +/// AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests. +/// +public sealed class CharacterCreationAppearancePageSwatchColorTests +{ + private const uint HeritageId = 1u; + private const int GenderKey = 1; + + private const uint HairPalSetIdA = 0x0F00_0001u; + private const uint HairPalSetIdB = 0x0F00_0002u; + private const uint EyePaletteIdA = 0x0400_0010u; + private const uint EyePaletteIdB = 0x0400_0011u; + private const uint HeadgearClothingTableId = 0x1900_0001u; + private const uint HeadgearDyePalSetId = 0x0F00_0010u; + private const uint SkinPalSetId = 0x0F00_0099u; + + private static readonly ChargenSwatchRgb HairColorA = new(10, 20, 30); + private static readonly ChargenSwatchRgb HairColorB = new(40, 50, 60); + private static readonly ChargenSwatchRgb EyeColorA = new(70, 80, 90); + private static readonly ChargenSwatchRgb EyeColorB = new(100, 110, 120); + private static readonly ChargenSwatchRgb HeadgearColorA = new(130, 140, 150); + private static readonly ChargenSwatchRgb SkinColor = new(160, 170, 180); + + private sealed class FakePalSetSource : IChargenPalSetSource + { + private readonly Dictionary _sets = new(); + public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds); + public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null; + } + + private sealed class FakeClothingTableSource : IChargenClothingTableSource + { + private readonly Dictionary _tables = new(); + public void Add(uint id, ChargenClothingTable table) => _tables[id] = table; + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _tables.TryGetValue(clothingTableId, out var t) ? t : null; + } + + private sealed class FakeColorSource : IChargenPaletteColorSource + { + private readonly Dictionary _colors = new(); + public void Add(uint paletteId, ChargenSwatchRgb color) => _colors[paletteId] = color; + public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) => + _colors.TryGetValue(paletteId, out color); + } + + private static ChargenGenderOptions MakeGender() => new( + GenderKey: GenderKey, + Name: "Male", + Scale: 100u, + SetupId: 0x0200_0001u, + SoundTableId: 0u, + IconId: 0u, + BasePaletteId: 0u, + SkinPalSetId: SkinPalSetId, + PhysicsTableId: 0u, + MotionTableId: 0u, + CombatTableId: 0u, + BaseObjDesc: ChargenObjDesc.Empty, + HairColors: [HairPalSetIdA, HairPalSetIdB], + HairStyles: [new ChargenHairStyle(0u, false, 0u, ChargenObjDesc.Empty)], + EyeColors: [EyePaletteIdA, EyePaletteIdB], + EyeStrips: [new ChargenEyeStrip(0u, 0u, ChargenObjDesc.Empty, ChargenObjDesc.Empty)], + NoseStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)], + MouthStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)], + Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0u)], + Shirts: [], + Pants: [], + Footwear: [], + ClothingColors: [9u]); + + private static ChargenOptions MakeOptions(uint heritageId, ChargenGenderOptions gender) + { + var heritage = new ChargenHeritageOptions( + heritageId, "Test", 0u, 0x0200_0001u, 0u, + 180u, 100u, [0], [], + new Dictionary(), [], + new Dictionary { [GenderKey] = gender }); + return new ChargenOptions( + [], + new Dictionary { [heritageId] = heritage }, + new Dictionary()); + } + + private static (FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors) MakeSources() + { + var pal = new FakePalSetSource(); + pal.Add(HairPalSetIdA, 0x0400_0001u); + pal.Add(HairPalSetIdB, 0x0400_0002u); + pal.Add(HeadgearDyePalSetId, 0x0400_0003u); + pal.Add(SkinPalSetId, 0x0400_0004u); + + var colors = new FakeColorSource(); + colors.Add(0x0400_0001u, HairColorA); + colors.Add(0x0400_0002u, HairColorB); + colors.Add(EyePaletteIdA, EyeColorA); + colors.Add(EyePaletteIdB, EyeColorB); + colors.Add(0x0400_0003u, HeadgearColorA); + colors.Add(0x0400_0004u, SkinColor); + + var choice = new ChargenClothingSubPaletteChoice(HeadgearDyePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]); + var templates = new Dictionary + { + [9u] = new ChargenClothingPaletteTemplate([choice]), + }; + var clothing = new FakeClothingTableSource(); + clothing.Add(HeadgearClothingTableId, new ChargenClothingTable( + new Dictionary(), templates)); + + return (pal, clothing, colors); + } + + private sealed class FakeView(ChargenOptions options, uint heritageId) : IRuntimeCharacterCreationView + { + public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = new( + new RuntimeGenerationToken(1u), + IsActive: true, + Revision: 1, + HeritageId: heritageId, + GenderKey: (uint)GenderKey, + Appearance: RuntimeCharacterCreationAppearance.Default, + Template: RuntimeCharacterCreationSnapshot.TemplateUnset, + Attributes: default, + AttributeLockMask: 0u, + TotalAttributeCredits: 0u, + RemainingAttributeCredits: 0, + TotalSkillCredits: 0u, + RemainingSkillCredits: 0, + Name: string.Empty, + StartArea: -1, + Slot: 0u, + VerificationPending: false, + LastLocalRefusal: default, + LastRejection: null, + LastCreated: null); + + public ChargenOptions Options { get; } = options; + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => ChargenSkillAdvancementClass.Inactive; + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => NullSubscription.Instance; + + private sealed class NullSubscription : IDisposable + { + public static readonly NullSubscription Instance = new(); + public void Dispose() { } + } + } + + /// Minimal appearance-page fixture — just what + /// 's constructor resolves + /// (spins for Hair/Eyes/Headgear used to drive _currentPart via + /// their own select-zone click, matching every other test in this + /// campaign's CharacterCreationUiControllerTests harness; the + /// nine swatches + the gradient disc, this test's own subject). + private static UiElement BuildPageRoot() + { + var page = new ElementInfo + { + Id = 0x100003D4u, + Type = 3u, + Width = 800f, + Height = 500f, + }; + + page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HairSpinId)); + page.Children.Add(SpinInfo(CharacterCreationAppearancePage.EyesSpinId)); + page.Children.Add(SpinInfo(CharacterCreationAppearancePage.SkinSpinId)); + page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HeadgearSpinId)); + + foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) + page.Children.Add(ButtonInfo(swatchId)); + foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds) + page.Children.Add(ContainerInfo(overlayId)); + + page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId)); + + return LayoutImporter.Build(page, _ => (0u, 0, 0), null).Root; + } + + private static ElementInfo SpinInfo(uint id) + { + var spin = new ElementInfo { Id = id, Type = 1u, Width = 200f, Height = 24f }; + spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f }); + spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f }); + return spin; + } + + private static ElementInfo ButtonInfo(uint id) => new() { Id = id, Type = 1u, Width = 20f, Height = 20f }; + private static ElementInfo ContainerInfo(uint id) => new() { Id = id, Type = 3u, Width = 64f, Height = 64f }; + + private static (CharacterCreationAppearancePage Page, FakeView View, UiElement Root) BuildPage( + FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors) + { + var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId); + var bindings = new CharacterCreationRuntimeBindings( + () => view, + _ => default, + _ => default, + _ => default, + (_, _) => default, + (_, _) => default, + _ => default, + _ => default, + _ => default, + _ => default, + _ => default, + () => { }, + SetAppearanceIndex: (_, _) => default); + + UiElement pageRoot = BuildPageRoot(); + var page = new CharacterCreationAppearancePage(pageRoot, bindings) + { + PalSetSource = pal, + ClothingTableSource = clothing, + PaletteColorSource = colors, + }; + return (page, view, pageRoot); + } + + private static UiButton Swatch(UiElement pageRoot, int index) => + Assert.IsType(UiElement.FindDescendant( + pageRoot, CharacterCreationAppearancePage.SwatchIds[index])); + + private static ChargenSwatchColorTile SwatchTile(UiElement pageRoot, int index) => + Assert.IsType(Assert.Single(Swatch(pageRoot, index).Children)); + + private static ChargenSwatchColorTile GradTile(UiElement pageRoot) => + Assert.IsType(Assert.Single( + UiElement.FindDescendant(pageRoot, CharacterCreationAppearancePage.GradCircleId)!.Children)); + + private static Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f); + + [Fact] + public void HairPart_PaintsBothSwatchesWithTheirOwnDistinctColors() + { + var (pal, clothing, colors) = MakeSources(); + (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); + + page.Refresh(view, view.Snapshot); + + Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); + Assert.True(SwatchTile(root, 0).Visible); + Assert.Equal(ToVector4(HairColorB), SwatchTile(root, 1).Color); + Assert.True(SwatchTile(root, 1).Visible); + // Only two hair colors exist — swatch 2 must be blank. + Assert.Null(SwatchTile(root, 2).Color); + Assert.False(SwatchTile(root, 2).Visible); + } + + /// Part change (Hair -> Eyes via the spin's own select-zone + /// click) must recompute the whole swatch set from the NEW part's own + /// color source. + [Fact] + public void PartChange_RecomputesTheSwatchSet() + { + var (pal, clothing, colors) = MakeSources(); + (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); + page.Refresh(view, view.Snapshot); + Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); + + UiButton eyesSpin = Assert.IsType( + UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId)); + eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change. + + Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color); + Assert.Equal(ToVector4(EyeColorB), SwatchTile(root, 1).Color); + } + + /// Color change (clicking a different swatch) must retint the + /// gradient disc to the NEWLY selected swatch's own color. + [Fact] + public void ColorChange_RetintsTheGradientDiscToTheNewlySelectedSwatch() + { + var (pal, clothing, colors) = MakeSources(); + (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); + page.Refresh(view, view.Snapshot); + // No color selected yet (Unset) — no tint. + Assert.Null(GradTile(root).Color); + Assert.False(GradTile(root).Visible); + + Swatch(root, 0).OnClick!(); + view.Snapshot = view.Snapshot with + { + Appearance = view.Snapshot.Appearance with { HairColor = 0u }, + }; + page.Refresh(view, view.Snapshot); + Assert.Equal(ToVector4(HairColorA), GradTile(root).Color); + Assert.True(GradTile(root).Visible); + + Swatch(root, 1).OnClick!(); + view.Snapshot = view.Snapshot with + { + Appearance = view.Snapshot.Appearance with { HairColor = 1u }, + }; + page.Refresh(view, view.Snapshot); + Assert.Equal(ToVector4(HairColorB), GradTile(root).Color); + } + + /// AP-217: Eyes always blanks the gradient disc's tile — no + /// tint is ever shown for Eyes, regardless of the selected eye color. + [Fact] + public void EyesPart_GradientDiscTileStaysBlank() + { + var (pal, clothing, colors) = MakeSources(); + (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); + view.Snapshot = view.Snapshot with + { + Appearance = view.Snapshot.Appearance with { EyeColor = 0u }, + }; + page.Refresh(view, view.Snapshot); + + UiButton eyesSpin = Assert.IsType( + UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId)); + eyesSpin.OnClickAt!(180, 10); + + Assert.False(GradTile(root).Visible); + Assert.Null(GradTile(root).Color); + // The swatches themselves still show real eye colors — only the + // disc blanks. + Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color); + } + + /// Nose/Mouth/Skin (colorSlot == null): retail still shows + /// exactly ONE representative swatch, sourced from the shared skin + /// PalSet, and the gradient disc tints from that SAME swatch — see + /// ComputeSwatchColors's own doc. + [Fact] + public void SkinPart_ShowsExactlyOneRepresentativeSwatchAndTintsTheDiscFromIt() + { + var (pal, clothing, colors) = MakeSources(); + (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); + page.Refresh(view, view.Snapshot); + + UiButton skinSpin = Assert.IsType( + UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId)); + skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it. + + Assert.Equal(ToVector4(SkinColor), SwatchTile(root, 0).Color); + Assert.True(SwatchTile(root, 0).Visible); + Assert.Null(SwatchTile(root, 1).Color); + Assert.False(SwatchTile(root, 1).Visible); + Assert.Equal(ToVector4(SkinColor), GradTile(root).Color); + Assert.True(GradTile(root).Visible); + } + + /// Headgear's swatch resolves through the CURRENTLY EQUIPPED + /// garment's own ClothingTable — an Unset headgear style (no garment) + /// shows no swatches at all. + [Fact] + public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches() + { + var (pal, clothing, colors) = MakeSources(); + (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); + view.Snapshot = view.Snapshot with + { + Appearance = view.Snapshot.Appearance with { HeadgearStyle = 0u }, + }; + page.Refresh(view, view.Snapshot); + + UiButton headgearSpin = Assert.IsType( + UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId)); + headgearSpin.OnClickAt!(180, 10); + + Assert.Equal(ToVector4(HeadgearColorA), SwatchTile(root, 0).Color); + + // Now un-equip (Unset) and refresh again — no garment, no colors. + view.Snapshot = view.Snapshot with + { + Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset }, + }; + page.Refresh(view, view.Snapshot); + Assert.Null(SwatchTile(root, 0).Color); + Assert.False(SwatchTile(root, 0).Visible); + } + + /// Heritage/gender change must re-source the color computation + /// from the NEW gender's own option lists — a second, differently- + /// colored fixture proves the recompute isn't cached from the first. + [Fact] + public void HeritageChange_RecomputesFromTheNewGendersOwnColorLists() + { + var (pal, clothing, colors) = MakeSources(); + (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); + page.Refresh(view, view.Snapshot); + Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); + + // A second heritage with a DIFFERENT hair-color list. + const uint otherHairPalSetId = 0x0F00_00AAu; + var otherHairColor = new ChargenSwatchRgb(200, 201, 202); + pal.Add(otherHairPalSetId, 0x0400_00AAu); + colors.Add(0x0400_00AAu, otherHairColor); + const uint otherHeritageId = 2u; + ChargenGenderOptions otherGender = MakeGender() with { HairColors = [otherHairPalSetId] }; + ChargenOptions otherOptions = MakeOptions(otherHeritageId, otherGender); + var otherView = new FakeView(otherOptions, otherHeritageId); + + page.Refresh(otherView, otherView.Snapshot); + + Assert.Equal(ToVector4(otherHairColor), SwatchTile(root, 0).Color); + } + + /// Before / + /// / + /// are + /// wired (composition-root STOPPED item), every tile MUST stay + /// invisible — the mechanism is fully inert, not a half-broken draw. + [Fact] + public void UnwiredSources_LeaveEveryTileInvisible() + { + var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId); + var bindings = new CharacterCreationRuntimeBindings( + () => view, + _ => default, + _ => default, + _ => default, + (_, _) => default, + (_, _) => default, + _ => default, + _ => default, + _ => default, + _ => default, + _ => default, + () => { }, + SetAppearanceIndex: (_, _) => default); + UiElement root = BuildPageRoot(); + var page = new CharacterCreationAppearancePage(root, bindings); // sources left null. + + page.Refresh(view, view.Snapshot); + + Assert.False(GradTile(root).Visible); + for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++) + Assert.False(SwatchTile(root, i).Visible); + } +} diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogColorTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogColorTests.cs new file mode 100644 index 00000000..75666ece --- /dev/null +++ b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogColorTests.cs @@ -0,0 +1,234 @@ +using AcDream.Content.CharGen; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit.Abstractions; + +namespace AcDream.Content.Tests.CharGen; + +/// +/// Installed-DAT gate for 's +/// implementation, PLUS +/// exercised end-to-end against +/// real dat data — Campaign CC gate round 1 Batch G (R2-5, register +/// AP-216/AP-217). Values below are MEASURED against the installed EoR dat +/// (Aluvian male, heritage 1 / gender 1 — the same probe subject +/// ChargenAppearanceCatalogInstalledDatTests uses), not assumed: a +/// live probe (ScratchPaletteProbe, this batch's throwaway +/// investigation harness) printed every value pinned here before it was +/// written into an assertion. +/// +/// Env-gated skip (house pattern, matched from +/// ChargenAppearanceCatalogInstalledDatTests): returns green with a +/// console SKIP note when no installed dat directory is configured. +/// +public sealed class ChargenAppearanceCatalogColorTests +{ + private readonly ITestOutputHelper _out; + public ChargenAppearanceCatalogColorTests(ITestOutputHelper output) => _out = output; + + private const uint AluvianId = 1u; + private const int MaleGenderKey = 1; + + private static string? ResolveDatDir() + { + string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + string def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + private static (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog)? LoadAluvianMale( + DatCollectionAdapter adapter) + { + ChargenOptions options = ChargenTableReader.Load(adapter); + if (!options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? heritage)) + return null; + if (!heritage.GendersByKey.TryGetValue(MaleGenderKey, out ChargenGenderOptions? gender)) + return null; + return (gender, new ChargenAppearanceCatalog(adapter)); + } + + /// + /// Direct pin — the + /// Eye family's shape (no PalSet indirection): reading the SAME palette + /// id/index pair twice returns the SAME color (cache correctness) and + /// matches the measured pixel. + /// + [Fact] + public void TryGetColor_EyePaletteAtFixedIndex_MatchesMeasuredPixel() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var loaded = LoadAluvianMale(adapter); + Assert.NotNull(loaded); + (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value; + Assert.NotEmpty(gender.EyeColors); + + bool ok = catalog.TryGetColor( + gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color); + + Assert.True(ok); + Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color); + + // Re-reading the SAME palette (cache hit path) is byte-identical. + catalog.TryGetColor(gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb again); + Assert.Equal(color, again); + } + + [Fact] + public void TryGetColor_OutOfRangeIndex_ReturnsFalse() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var loaded = LoadAluvianMale(adapter); + Assert.NotNull(loaded); + (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value; + + bool ok = catalog.TryGetColor(gender.EyeColors[0], index: int.MaxValue, out _); + + Assert.False(ok); + } + + /// + /// End-to-end through + /// for Hair — averaged across every one of Aluvian male's 13 hair-color + /// PalSet's own sub-palettes. + /// + [Fact] + public void HairSwatchZero_AveragedAcrossPalSet_MatchesMeasuredColor() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var loaded = LoadAluvianMale(adapter); + Assert.NotNull(loaded); + (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value; + Assert.NotEmpty(gender.HairColors); + + bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + catalog, catalog, gender.HairColors[0], ChargenSwatchColorResolver.HairSampleIndex, + out ChargenSwatchRgb color); + + Assert.True(ok); + Assert.Equal(new ChargenSwatchRgb(101, 94, 4), color); + } + + /// Distinct hair-color swatches must resolve to DISTINCT + /// colors — the whole point of the mechanism (R2-5's complaint was that + /// every swatch showed the SAME static art regardless of which color it + /// represents). + [Fact] + public void EveryHairColorSwatch_ResolvesToADistinctColor() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var loaded = LoadAluvianMale(adapter); + Assert.NotNull(loaded); + (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value; + Assert.True(gender.HairColors.Count > 1, "fixture assumption: Aluvian male has >1 hair color choice"); + + var seen = new HashSet(); + foreach (uint palSetId in gender.HairColors) + { + bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + catalog, catalog, palSetId, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color); + Assert.True(ok); + _out.WriteLine($"hair palSet=0x{palSetId:X8} rgb=({color.R},{color.G},{color.B})"); + Assert.True(seen.Add(color), $"duplicate representative color {color} for palSet 0x{palSetId:X8}"); + } + } + + /// + /// End-to-end through the clothing family's two-step lookup + /// ( + /// then ) + /// for Aluvian male's first headgear garment. + /// + [Fact] + public void HeadgearSwatchZero_ResolvesThroughTheEquippedGarmentsClothingTable() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var loaded = LoadAluvianMale(adapter); + Assert.NotNull(loaded); + (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value; + Assert.NotEmpty(gender.Headgears); + Assert.NotEmpty(gender.ClothingColors); + + ChargenGearOption firstHeadgear = gender.Headgears[0]; + bool palSetOk = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId( + catalog, firstHeadgear.ClothingTableId, gender.ClothingColors[0], out uint palSetId); + Assert.True(palSetOk); + Assert.Equal(0x0F000009u, palSetId); + + bool colorOk = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + catalog, catalog, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb color); + Assert.True(colorOk); + Assert.Equal(new ChargenSwatchRgb(59, 59, 59), color); + } + + /// The Nose/Mouth/Skin family's shared single representative + /// swatch — sourced from , + /// not any per-part list. + [Fact] + public void SkinFamilySwatch_ResolvesFromTheSharedSkinPalSet() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var loaded = LoadAluvianMale(adapter); + Assert.NotNull(loaded); + (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value; + + bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + catalog, catalog, gender.SkinPalSetId, ChargenSwatchColorResolver.SkinFamilySampleIndex, + out ChargenSwatchRgb color); + + Assert.True(ok); + Assert.Equal(new ChargenSwatchRgb(182, 148, 118), color); // a plausible flesh tone. + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenSwatchColorResolverTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenSwatchColorResolverTests.cs new file mode 100644 index 00000000..3226acad --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenSwatchColorResolverTests.cs @@ -0,0 +1,212 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Hand-built-fixture tests for — +/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217). Real +/// installed-DAT coverage (pinned RGB values off the actual client dats) +/// lives in AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests. +/// +public sealed class ChargenSwatchColorResolverTests +{ + private sealed class FakePalSetSource : IChargenPalSetSource + { + private readonly Dictionary _sets = new(); + public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds); + public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null; + } + + private sealed class FakeClothingTableSource : IChargenClothingTableSource + { + private readonly Dictionary _tables = new(); + public void Add(uint id, ChargenClothingTable table) => _tables[id] = table; + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _tables.TryGetValue(clothingTableId, out var t) ? t : null; + } + + /// Fixed-color fake: every (paletteId, index) pair the test + /// registers resolves to an EXACT color, so averaging math can be + /// checked by hand rather than against opaque installed-DAT pixels. + private sealed class FakeColorSource : IChargenPaletteColorSource + { + private readonly Dictionary<(uint paletteId, int index), ChargenSwatchRgb> _colors = new(); + public void Add(uint paletteId, int index, byte r, byte g, byte b) => + _colors[(paletteId, index)] = new ChargenSwatchRgb(r, g, b); + + public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) => + _colors.TryGetValue((paletteId, index), out color); + } + + // ── TryGetPalSetAverageColor ──────────────────────────────────────── + + [Fact] + public void TryGetPalSetAverageColor_AveragesEveryPaletteInTheSet() + { + var palSets = new FakePalSetSource(); + palSets.Add(0x0F00_0001u, 0x0400_0001u, 0x0400_0002u); + var colors = new FakeColorSource(); + colors.Add(0x0400_0001u, 0xd0, r: 100, g: 0, b: 0); + colors.Add(0x0400_0002u, 0xd0, r: 200, g: 0, b: 0); + + bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, 0x0F00_0001u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color); + + Assert.True(ok); + Assert.Equal(new ChargenSwatchRgb(150, 0, 0), color); + } + + /// Retail's own loop (@0x0047e759-0x0047e80f) accumulates + /// unconditionally and ALWAYS divides by the full num_pals — a per- + /// entry miss contributes (0,0,0) rather than shrinking the divisor + /// (GetColorFromPal's own miss path @0x005639b5 returns 0, it does not + /// skip the accumulation). + [Fact] + public void TryGetPalSetAverageColor_MissingIndividualPaletteContributesBlackNotSkip() + { + var palSets = new FakePalSetSource(); + palSets.Add(0x0F00_0002u, 0x0400_0010u, 0x0400_0011u); // second id never registered in colors. + var colors = new FakeColorSource(); + colors.Add(0x0400_0010u, 0xd0, r: 200, g: 100, b: 50); + + bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, 0x0F00_0002u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color); + + Assert.True(ok); + // (200+0)/2=100, (100+0)/2=50, (50+0)/2=25 — divided by the FULL + // count of 2, not 1. + Assert.Equal(new ChargenSwatchRgb(100, 50, 25), color); + } + + /// Retail's explicit zero-init before the num_pals>0 guard + /// (@0x0047e790) — an empty-but-resolved PalSet is BLACK, not a miss. + [Fact] + public void TryGetPalSetAverageColor_EmptyPalSet_ReturnsTrueBlack() + { + var palSets = new FakePalSetSource(); + palSets.Add(0x0F00_0003u); // zero palette ids. + var colors = new FakeColorSource(); + + bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, 0x0F00_0003u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color); + + Assert.True(ok); + Assert.Equal(new ChargenSwatchRgb(0, 0, 0), color); + } + + [Fact] + public void TryGetPalSetAverageColor_UnresolvedPalSetId_ReturnsFalse() + { + var palSets = new FakePalSetSource(); + var colors = new FakeColorSource(); + + bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, 0x0F00_DEADu, ChargenSwatchColorResolver.HairSampleIndex, out _); + + Assert.False(ok); + } + + // ── TryGetDirectColor (Eyes) ───────────────────────────────────────── + + [Fact] + public void TryGetDirectColor_ReadsThePaletteDirectly_NoPalSetIndirection() + { + var colors = new FakeColorSource(); + colors.Add(0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, r: 15, g: 63, b: 93); + + bool ok = ChargenSwatchColorResolver.TryGetDirectColor( + colors, 0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color); + + Assert.True(ok); + Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color); + } + + [Fact] + public void TryGetDirectColor_UnresolvedPaletteId_ReturnsFalse() + { + var colors = new FakeColorSource(); + + bool ok = ChargenSwatchColorResolver.TryGetDirectColor( + colors, 0x0400_DEADu, ChargenSwatchColorResolver.EyeSampleIndex, out _); + + Assert.False(ok); + } + + // ── TryGetClothingSwatchPalSetId ───────────────────────────────────── + + private static ChargenClothingTable MakeClothingTable(uint templateId, uint firstChoicePalSetId, uint secondChoicePalSetId) + { + var template = new ChargenClothingPaletteTemplate( + [ + new ChargenClothingSubPaletteChoice(firstChoicePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]), + new ChargenClothingSubPaletteChoice(secondChoicePalSetId, [new ChargenClothingSubPaletteRange(8, 8)]), + ]); + return new ChargenClothingTable( + new Dictionary(), + new Dictionary { [templateId] = template }); + } + + [Fact] + public void TryGetClothingSwatchPalSetId_ReturnsTheFIRSTChoicesPalSetId() + { + var clothingTables = new FakeClothingTableSource(); + clothingTables.Add(0x1900_0001u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u)); + + bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId( + clothingTables, 0x1900_0001u, paletteTemplateId: 9u, out uint palSetId); + + Assert.True(ok); + Assert.Equal(0x0F00_0010u, palSetId); // choices[0], not choices[1]. + } + + [Fact] + public void TryGetClothingSwatchPalSetId_UnresolvedClothingTable_ReturnsFalse() + { + var clothingTables = new FakeClothingTableSource(); + + bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId( + clothingTables, 0x1900_DEADu, paletteTemplateId: 9u, out _); + + Assert.False(ok); + } + + [Fact] + public void TryGetClothingSwatchPalSetId_TemplateIdNotInThisGarmentsTable_ReturnsFalse() + { + var clothingTables = new FakeClothingTableSource(); + clothingTables.Add(0x1900_0002u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u)); + + bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId( + clothingTables, 0x1900_0002u, paletteTemplateId: 999u, out _); + + Assert.False(ok); + } + + [Fact] + public void TryGetClothingSwatchPalSetId_TemplateWithNoChoices_ReturnsFalse() + { + var clothingTables = new FakeClothingTableSource(); + var emptyTemplate = new ChargenClothingPaletteTemplate([]); + clothingTables.Add( + 0x1900_0003u, + new ChargenClothingTable( + new Dictionary(), + new Dictionary { [5u] = emptyTemplate })); + + bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId( + clothingTables, 0x1900_0003u, paletteTemplateId: 5u, out _); + + Assert.False(ok); + } + + // ── Fixed sample-index constants (decomp anchors, gmCGAppearancePage::SetSelection) ── + + [Fact] + public void SampleIndexConstants_MatchTheDecompiledLiterals() + { + Assert.Equal(0xd0, ChargenSwatchColorResolver.HairSampleIndex); + Assert.Equal(0xb0, ChargenSwatchColorResolver.SkinFamilySampleIndex); + Assert.Equal(0x103, ChargenSwatchColorResolver.EyeSampleIndex); + Assert.Equal(0x520, ChargenSwatchColorResolver.ClothingSampleIndex); + } +} From 8c30aa18ee9293dfc2c99f597249c706ee388e99 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 14:18:13 +0200 Subject: [PATCH 124/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20Batch=20F=20=E2=80=94=20Skills=20page=20buckets,=20?= =?UTF-8?q?selection,=20info=20box,=20cost=20text,=20arrow=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-4/review F1-F2 (gmCGSkillsPage): row click (and arrow click, matching retail's own post-Increase/DecreaseSkillLevel re-select) now selects a skill, highlights its row name, and writes the info panes' title (name + score) and a level-gated bonus line; the description/formula halves stay unported (SkillBase._description/_formula unreachable from this page's current data surface, documented on RefreshInfoBox). The listbox's own authored scrollbar link is wired to its Scroll model (live-DAT-confirmed at 0x100003F8, matching the "+1 from the listbox" hypothesis). Cost text now matches SetSkillText @0x00480600 exactly: Untrained's down-cost and Specialized's up-cost are literal "0", unconditional, where the port previously rendered blank; the 999-blank gate applies to the up-cost only, never to a down-cost. Arrow Ghosted/Enabled state (0x1000001a/ 0x1000001b) is now gated per branch, including bUntrainable/ bUnspecializable re-derived as "this row's own effective cost is nonzero" — no new data needed since the page already resolves that cost. R2-4b (the four-bucket sorted model) is NOT implemented — its Useable- vs-Unuseable-Untrained split reads SkillBase.MinLevel, confirmed present in the installed dat (SkillTable_MinLevelDistribution_NeverExceedsTrained) but not threaded through ChargenOptions/ChargenHeritageOptions/ CharacterCreationRuntimeBindings. AP-213 row records the exact channel a future fix needs. Also live-DAT-pinned: Templates[0]'s header-caption child (0x100002f6) resolves as a UiButton, not UiText, in the real dat — the same UIElement_Button-is-DynamicCast(0xc)-compatible-with-Text quirk already ported for GF-4b's slider labels. App suite (live-DAT env) 5321/3 -> 5328/3 (+7, zero regressions). Runtime 1735/0 unchanged. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- ...-08-16-campaign-cc-gate-round1-findings.md | 30 ++ .../UI/Layout/CharacterCreationSkillsPage.cs | 309 ++++++++++++++++-- .../Layout/CharacterCreationLiveDatTests.cs | 60 ++++ .../CharacterCreationUiControllerTests.cs | 247 +++++++++++++- 5 files changed, 615 insertions(+), 33 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3f22d4e9..517dd5cc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -401,7 +401,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | -| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0` | +| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. **Batch F investigation (Campaign CC gate round 1, 2026-08-16 — R2-4b): the remaining flat-list-vs-four-bucket half's blocker is now PRECISELY IDENTIFIED, still NOT implemented.** Retail's `UpdateSkillEntry @0x00480bf0` splits Untrained-class rows into UseableUntrained/UnuseableUntrained via `arg2->iMinlevel <= 1` — `iMinlevel` copies `SkillBase._min_level` (portal.dat SkillTable, live-DAT-confirmed present and populated — `SkillTable_MinLevelDistribution_NeverExceedsTrained` measures 23 skills at MinLevel=1, 15 at MinLevel=2 in the installed dat). `AcDream.Core.CharGen.ChargenOptions`/`ChargenHeritageOptions`/`ChargenSkillCost` carry per-skill COSTS only; MinLevel is not threaded through CC1's `ChargenTableReader` at all, and `CharacterCreationRuntimeBindings` has no resolver for it (unlike `GetSkillScore`, which already exists for the analogous per-skill score lookup, `AcDream.App.Net.ChargenSkillScoreResolver`). Closing this row for real needs: (1) `ChargenSkillCost` (or a sibling record) gains a `MinLevel`/useable-while-untrained field, (2) `ChargenTableReader.Load` populates it from `SkillBase.MinLevel`, (3) the page reads it directly (`ChargenOptions` is already reachable from `CharacterCreationSkillsPage`, no new binding needed once (1)/(2) land) to build the four header rows (`Templates[0]`, `0x100002F4` — its own caption child `0x100002f6` live-DAT-measured as a `UiButton`, read via `.Label`, not a `UiText` — the same `UIElement_Button`-is-`DynamicCast(0xc)`-compatible-with-Text quirk GF-4b already ported) and re-bucket on every level change (`InsertEntrySorted`'s own alphabetical-by-name category-relative insert). Batch F separately fixed two ADJACENT bugs found while re-deriving `SetSkillText`'s full per-branch behavior for this SAME row (review F1/F2 — plain bugs, not divergences, so no register rows of their own): the Untrained-down/Specialized-up literal-`"0"`-vs-blank cost text, and the `pSkillUpButton`/`pSkillDownButton` Ghosted/Enabled state pair (gated on `remainingSkillCredits` and a re-derived `bUntrainable`/`bUnspecializable` — the row's OWN effective trained/specialized cost being nonzero — using cost data this page already resolves, no new channel needed for those two). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`, `SelectRow`, `RefreshInfoBox`); `src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs` (`ChargenSkillCost` — the field that would need to grow); `src/AcDream.Content/CharGen/ChargenTableReader.cs` (the read site that would need to populate it) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. Separately, a player cannot yet tell "Useable Untrained" from "Unuseable Untrained" (both render identically, ungrouped) until the `MinLevel` channel above is wired — a second, narrower discoverability gap layered on the first. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0`; `gmCGSkillsPage::SetSkillText @ 0x00480600`; `gmCGSkillsPage::ShowSkillsText @ 0x00481250`; `gmCGSkillsPage::MakeSkillFormula @ 0x00480e10` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 83156c6f..1313c76e 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -107,6 +107,36 @@ by construction (`SetBackdrop` throws if called without a reserved slot, and the entity-list-assembly helper `BuildDrawEntities` degrades to exactly the main entity whenever no backdrop is configured/set). +**Batch F (Skills page completion — R2-4 + review F1/F2) is CODE-COMPLETE +2026-08-16, pending the user's visual gate.** Four of R2-4's five +sub-items are fixed; R2-4b (the four-bucket sorted model) is NOT — see the +AP-213 register row for the exact missing data channel this batch's +investigation pinned down (`SkillBase.MinLevel`, confirmed present in the +installed dat but not threaded through `ChargenOptions`/ +`CharacterCreationRuntimeBindings`). **Fixed:** R2-4a (row selection — a +row click, or an arrow click matching retail's own post-Increase/ +DecreaseSkillLevel re-select, highlights the row and writes the info +panes' TITLE — name + score — and a level-gated bonus line; the +description/formula halves stay unported for the SAME missing-data reason +as R2-4b, documented on `CharacterCreationSkillsPage.RefreshInfoBox`'s own +doc rather than a new register row since no file outside the page's own +scope was needed to identify it); R2-4c (the listbox's own authored +scrollbar link, live-DAT-CONFIRMED at `0x100003F8` — exactly this batch's +own "+1 from the listbox" hypothesis — wired to the listbox's `Scroll` +model, the ordinary page-level linkage every other `UiTemplateListBox` +owner uses); review F1 (the Untrained-down/Specialized-up literal `"0"` +cost text the prior port rendered blank, and the exact per-branch 999-blank +gate — up-cost only, never down-cost); review F2 (the +`pSkillUpButton`/`pSkillDownButton` Ghosted/Enabled state pair, gated on +credits and a re-derived `bUntrainable`/`bUnspecializable` — the row's own +effective cost being nonzero — using cost data the page already resolves, +no new channel needed). Fixture + one live-DAT test this round (no +graphical client launch); App suite live-DAT env went from 5321/3 to +5328/3 (+7, zero regressions — one pre-existing baseline flake, the +streaming "injected dungeon enqueue failure" test, is a known standalone- +pass-only flake unrelated to this batch and did not reproduce on the full +post-fix run), Runtime 1735/0 unchanged. + User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, windowed). Screenshots: retail Heritage, acdream Heritage, retail Profession. The user's side-by-side retail reports are AXIOMS diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index 2bb022a8..e7e0c231 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Numerics; using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -54,6 +55,59 @@ namespace AcDream.App.UI.Layout; /// row's still-simplified flat-list-vs-four-bucket half is untouched and /// stays registered). /// +/// +/// +/// Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review +/// F1/F2): four of R2-4's five sub-items are fixed here; the +/// four-bucket sorted model (R2-4b) is NOT — see the batch report and the +/// AP-213 row for the exact missing data channel (retail's Useable-vs- +/// Unuseable-Untrained split reads SkillBase.MinLevel, which +/// / +/// do not carry today). +/// +/// R2-4a (row selection): a row click (or an arrow click, matching +/// retail's own post-Increase/DecreaseSkillLevel SetSelectedItem(..., +/// 1) re-select) now selects that skill — the row's NAME text swaps to +/// (best-derived "brighter white" per the +/// user's own report + the GF-11b precedent) and the info panes +/// (0x100003fb/0x100003fc) get ShowSkillsText +/// @0x00481250's title (name + score, " (%d)\n") and bonus line +/// ("Training Bonus +5"/"Specialization Bonus +10") — a +/// PARTIAL port: the description (SkillBase._description) and +/// MakeSkillFormula @0x00480e10's computed formula text are not +/// reachable from this page's current data surface; see +/// 's own doc. +/// R2-4c (scrollbar): the listbox's own authored scrollbar link +/// (, dat +/// property 0x72) is now wired to +/// — the SAME +/// page-level UiScrollbar.Model linkage every other +/// UiTemplateListBox owner uses (no widget change). +/// Review F1 (cost text): SetSkillText's Untrained down-cost +/// (@0x00480877) and Specialized up-cost (@0x0048067f) are +/// literal "0", unconditional — the prior port rendered blank +/// (null) instead. The <0x3e7 (999) blank gate exists +/// ONLY on the up-cost at Untrained (@0x00480819) and Trained +/// (@0x0048071f); every down-cost write is unconditional +/// (@0x00480877/@0x00480780/@0x004806c1), including +/// Trained's raw iTrainCost even when it would exceed 999. +/// Review F2 (arrow states): SetSkillText ends every branch +/// driving pSkillUpButton/pSkillDownButton through its own +/// custom Ghosted/Enabled state pair (/ +/// — raw ids via +/// , the SAME "authored +/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on +/// remainingSkillCredits vs. the advance cost and is ALWAYS ghosted +/// at Specialized (nothing above it); Down is ALWAYS ghosted at Untrained +/// (nothing below it) and otherwise gated on bUntrainable/ +/// bUnspecializable — re-derived from DoSkillRecords's own +/// tagSkillRecord build (@0x00480e40-region) as "the row's OWN +/// effective trained/specialized cost is nonzero" (a free/heritage-granted +/// skill or specialization locks its own down arrow), using the SAME +/// heritage-then-global cost this page already resolves via +/// — no new data needed. +/// +/// /// internal sealed class CharacterCreationSkillsPage : IDisposable { @@ -84,18 +138,44 @@ internal sealed class CharacterCreationSkillsPage : IDisposable /// case 0x10000305 fires DecreaseSkillLevel. private const uint RowDownButtonId = 0x10000305u; + /// Retail's own custom Ghosted state id for + /// pSkillUpButton/pSkillDownButton (SetSkillText's + /// own SetState(0x1000001a) calls) — distinct from the standard + /// UiButtonStateMachine.Ghosted (13) numbering; the same + /// "authored custom pair, raw retail id" shape as GF-1's + /// Unselected/Selected (0x10000016/0x10000017). + private const uint ArrowGhostedStateId = 0x1000001Au; + + /// Retail's own custom Enabled state id for the same two + /// buttons (SetState(0x1000001b)). + private const uint ArrowEnabledStateId = 0x1000001Bu; + + /// R2-4a row-selection highlight: pure white. Re-derived from + /// the GF-11b precedent (list-caption color swap Normal + /// (218,167,85) -> Highlight/white (255,255,255) on + /// selection) plus the user's own report ("retail selection turns the + /// row brighter white") absent a skills-row-specific cdb capture — the + /// direction (unselected -> brighter/whiter) is directly evidenced; + /// the exact target RGB is the best available derivation, not a live + /// measurement. + private static readonly Vector4 SelectedNameColor = Vector4.One; + /// One built skill row: the resolved Templates[1] /// subtree plus the child widgets needs /// every tick, resolved once at build time rather than re-walked per - /// refresh. + /// refresh. is the row's OWN authored + /// (DAT-default) name color, captured at build time so R2-4a's + /// selection highlight can restore it exactly on deselect. private readonly record struct SkillRow( UiElement Root, uint SkillId, + UiText? NameText, UiText? LevelText, UiText? UpCostText, UiText? DownCostText, UiButton? UpButton, - UiButton? DownButton); + UiButton? DownButton, + Vector4 UnselectedNameColor); private readonly CharacterCreationRuntimeBindings _bindings; private readonly UiTemplateListBox? _list; @@ -104,6 +184,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable private readonly UiText? _infoText; private readonly List _rows = []; private uint _lastHeritageId; + private uint? _selectedSkillId; private bool _rowsBuilt; private bool _disposed; @@ -116,6 +197,20 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox; if (_list is not null) _list.TemplateResolver = templateResolver; + + // R2-4c (Batch F): wire the listbox's own authored scrollbar (dat + // property 0x72, UiTemplateListBox.ScrollbarElementId) the SAME + // page-level Model linkage every other UiTemplateListBox owner uses + // (ConfigOptionsPageController, SocialFriendsPageController, et + // al.) — no widget change, just resolving the id the importer + // already read and pointing its Model at this listbox's own Scroll. + if (_list is not null + && _list.ScrollbarElementId != 0 + && UiElement.FindDescendant(pageRoot, _list.ScrollbarElementId) is UiScrollbar scrollbar) + { + scrollbar.Model = _list.Scroll; + } + // Live-DAT probe (CharacterCreationLiveDatTests): the credits meter // (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw // dat CHILD of button 0x100003f9, not as a standalone descendant of @@ -152,6 +247,8 @@ internal sealed class CharacterCreationSkillsPage : IDisposable foreach (SkillRow row in _rows) RefreshRowValues(row, view, snapshot); + RefreshInfoBox(view, snapshot); + if (_credits is { } credits) credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); } @@ -162,10 +259,16 @@ internal sealed class CharacterCreationSkillsPage : IDisposable { if (row.UpButton is not null) row.UpButton.OnClick = null; if (row.DownButton is not null) row.DownButton.OnClick = null; + if (row.Root is UiDatElement datRoot) datRoot.OnClick = null; } _rows.Clear(); _list?.Flush(); + // The skill list is rebuilding under a (possibly new) heritage — + // any previously selected skill id may no longer exist as a row. + _selectedSkillId = null; + ClearInfoBox(); + if (_list is null || _list.Templates.Count < 2 || _list.TemplateResolver is null @@ -189,8 +292,14 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _list.AddPrebuiltRow(rowRoot); - if (UiElement.FindDescendant(rowRoot, RowNameTextId) is UiText nameText) + UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText; + if (nameText is not null) SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId)); + // Captured AFTER SetLine (which never touches DefaultColor — + // it's read lazily inside the LinesProvider closure) so this is + // the row's own DAT-authored default color, for R2-4a's + // selection highlight to restore on deselect. + Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One; UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText; UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText; UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText; @@ -198,13 +307,33 @@ internal sealed class CharacterCreationSkillsPage : IDisposable UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton; uint capturedSkillId = skillId; + // R2-4a: retail re-selects the row after an arrow click too + // (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1) + // call following IncreaseSkillLevel/DecreaseSkillLevel). if (upButton is not null) - upButton.OnClick = () => Advance(capturedSkillId); + upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); }; if (downButton is not null) - downButton.OnClick = () => Retreat(capturedSkillId); + downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); }; + + // R2-4a: the row-click equivalent of retail's listbox-level + // selection notification (idElement==0x100003f7 && + // idMessage==4 in ListenToElementMessage) — UiTemplateListBox + // has no generic selection mechanism of its own (see its class + // doc), so this page opts the row in directly. Templates[1] + // (0x100002FF) resolves through DatWidgetFactory's Type-3 + // (generic-container) fallback arm to UiDatElement, which + // already carries a page-opt-in OnClick/ClickThrough seam for + // exactly this — "generic decoration; behavioral widgets opt + // back in" (UiDatElement's own doc). + if (rowRoot is UiDatElement datRow) + { + datRow.ClickThrough = false; + datRow.OnClick = () => SelectRow(capturedSkillId); + } _rows.Add(new SkillRow( - rowRoot, skillId, levelText, upCostText, downCostText, upButton, downButton)); + rowRoot, skillId, nameText, levelText, upCostText, downCostText, + upButton, downButton, unselectedColor)); } } @@ -220,28 +349,71 @@ internal sealed class CharacterCreationSkillsPage : IDisposable if (row.LevelText is { } levelText) SetLine(levelText, score.ToString(CultureInfo.InvariantCulture)); - // SetSkillText @0x00480600's own per-state up/down cost pair: at - // Untrained, up=trainCost (down blank, nothing below Untrained); at - // Trained, up=(specCost-trainCost), down=trainCost; at Specialized, - // up=blank (nothing above Specialized), down=(specCost-trainCost). - // Retail also blanks a cost >= 999 (data_794320, an empty - // PStringBase) instead of showing the raw number. - (int? upCost, int? downCost) = level switch + // Review F1/F2 fix (Batch F): SetSkillText @0x00480600's exact + // per-state cost text + arrow-enable pair — see this class's own + // header doc for the full byte trace of every address cited below. + string upCostText; + string downCostText; + bool upEnabled; + bool downEnabled; + switch (level) { - ChargenSkillAdvancementClass.Specialized => - ((int?)null, (int?)(specializedCost - trainedCost)), - ChargenSkillAdvancementClass.Trained => - ((int?)(specializedCost - trainedCost), (int?)trainedCost), - _ => ((int?)trainedCost, (int?)null), - }; - if (row.UpCostText is { } upCostText) - SetLine(upCostText, FormatCost(upCost)); - if (row.DownCostText is { } downCostText) - SetLine(downCostText, FormatCost(downCost)); + case ChargenSkillAdvancementClass.Specialized: + // @0x0048067f: up = literal "0", unconditional (nothing + // above Specialized). @0x004806c1: down = specCost- + // trainCost, UNCONDITIONAL (no 999-blank gate). + // @0x004806fc: up arrow ALWAYS ghosted. @0x0048070c + + // @0x004807f1/@0x004807f4: down arrow enabled iff + // bUnspecializable — re-derived as specializedCost != 0 + // (a free/heritage-granted specialization, cost 0, locks + // its own down arrow — DoSkillRecords zeroes + // bUnspecializable exactly there, @0x00480e40 region). + upCostText = "0"; + downCostText = (specializedCost - trainedCost).ToString(CultureInfo.InvariantCulture); + upEnabled = false; + downEnabled = specializedCost != 0; + break; + case ChargenSkillAdvancementClass.Trained: + // @0x0048071f: up = specCost-trainCost, blank if >=999. + // @0x00480780: down = trainCost, UNCONDITIONAL (no gate, + // even past 999). @0x004807ce: up arrow enabled iff + // remainingSkillCredits >= specCost-trainCost. + // @0x004807ec + @0x004807f1/@0x004807f4: down arrow + // enabled iff bUntrainable — re-derived as trainedCost != 0 + // (same free-skill-locks-the-down-arrow rule, mirrored on + // the trained cost). + upCostText = FormatGatedCost(specializedCost - trainedCost); + downCostText = trainedCost.ToString(CultureInfo.InvariantCulture); + upEnabled = snapshot.RemainingSkillCredits >= specializedCost - trainedCost; + downEnabled = trainedCost != 0; + break; + default: + // Untrained/Inactive. @0x00480819: up = trainCost, blank if + // >=999. @0x00480877: down = literal "0", unconditional. + // @0x004808b3: down arrow ALWAYS ghosted (nothing below + // Untrained). @0x004808d1: up arrow enabled iff + // remainingSkillCredits >= trainCost. + upCostText = FormatGatedCost(trainedCost); + downCostText = "0"; + upEnabled = snapshot.RemainingSkillCredits >= trainedCost; + downEnabled = false; + break; + } + + if (row.UpCostText is { } upCostTextWidget) + SetLine(upCostTextWidget, upCostText); + if (row.DownCostText is { } downCostTextWidget) + SetLine(downCostTextWidget, downCostText); + row.UpButton?.TrySetRetailState(upEnabled ? ArrowEnabledStateId : ArrowGhostedStateId); + row.DownButton?.TrySetRetailState(downEnabled ? ArrowEnabledStateId : ArrowGhostedStateId); } - private static string FormatCost(int? cost) => - cost is int c && c < 999 ? c.ToString(CultureInfo.InvariantCulture) : string.Empty; + /// The up-cost-only 999 blank gate (< 0x3e7, + /// data_794320 — an empty PStringBase). Never applied to a + /// down-cost or a literal "0" write — see the per-branch citations in + /// . + private static string FormatGatedCost(int cost) => + cost < 999 ? cost.ToString(CultureInfo.InvariantCulture) : string.Empty; private static void SetLine(UiText text, string content) => text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; @@ -301,6 +473,92 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _bindings.UntrainSkill(skillId); } + /// + /// R2-4a: row click / arrow click selection — the port's equivalent of + /// retail's listbox-level SetSelectedItem notification (see + /// 's own wiring doc). Applies the highlight + /// to every row (so the PREVIOUSLY selected row also gets restored to + /// its own ) and refreshes + /// the info panes for the newly selected skill. ' + /// View is resolved fresh here, never cached, per + /// feedback_resolve_deferred_funcs_per_call.md. + /// + private void SelectRow(uint skillId) + { + if (_disposed) + return; + _selectedSkillId = skillId; + foreach (SkillRow row in _rows) + { + if (row.NameText is { } nameText) + nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor; + } + if (_bindings.View() is { } view) + RefreshInfoBox(view, view.Snapshot); + } + + /// + /// gmCGSkillsPage::ShowSkillsText @0x00481250 — writes + /// m_pInfoBoxTitle (0x100003fb) and m_pInfoBoxText + /// (0x100003fc) for the currently selected skill, or clears both + /// when nothing is selected (retail's own arg2==0/lookup-miss + /// arms, both UIElement_Text::ClearAllText). Title is the skill + /// name plus its current score (" (%d)\n", e.g. "Loyalty (5)"). + /// Body is level-gated bonus text + /// ("Training Bonus +5"/"Specialization Bonus +10" — + /// TWO spaces before the number, matching the compiled literal + /// verbatim) only. + /// + /// + /// PARTIAL PORT — see the batch report: retail's body ALSO + /// prepends the skill's DESCRIPTION (SkillBase._description, + /// read via eax_2[7] off the row's own cached + /// tagSkillRecord) and appends + /// MakeSkillFormula @0x00480e10's computed "Formula : ..." text + /// (attribute names + weighted-formula arithmetic, sourced from + /// SkillBase._formula). Neither is reachable from this page's + /// current data surface: + /// carries per-skill COSTS only (never description/formula), and + /// has no resolver for + /// either (unlike , + /// which already exists for the score). Porting them needs a new + /// binding of that same shape, backed by the global SkillTable — out of + /// this file's edit contract for this batch. + /// + /// + private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) + { + if (_selectedSkillId is not { } skillId) + { + ClearInfoBox(); + return; + } + + ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); + uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, level) ?? 0u; + string name = ItemAppraisalTextFormatter.SkillName((int)skillId); + + if (_infoTitle is { } title) + SetLine(title, $"{name} ({score.ToString(CultureInfo.InvariantCulture)})"); + + if (_infoText is { } text) + { + string bonus = level switch + { + ChargenSkillAdvancementClass.Trained => "Training Bonus +5", + ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10", + _ => string.Empty, + }; + SetLine(text, bonus); + } + } + + private void ClearInfoBox() + { + if (_infoTitle is { } title) SetLine(title, string.Empty); + if (_infoText is { } text) SetLine(text, string.Empty); + } + public void Dispose() { if (_disposed) @@ -310,6 +568,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable { if (row.UpButton is not null) row.UpButton.OnClick = null; if (row.DownButton is not null) row.DownButton.OnClick = null; + if (row.Root is UiDatElement datRow) datRow.OnClick = null; } _rows.Clear(); _list?.Flush(); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 892439fe..0ba3def2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1045,6 +1045,66 @@ public sealed class CharacterCreationLiveDatTests Assert.Equal(0x100002F4u, list.Templates[0].TemplateElementId); } + /// + /// R2-4c (Campaign CC gate round 1 Batch F). Live-DAT probe: pins the + /// Skills listbox's own authored scrollbar link (dat property + /// 0x72, ) — + /// CONFIRMED as 0x100003F8, exactly the "+1 from the listbox" + /// hypothesis this batch's own investigation raised — and confirms a + /// real resolves at that id under the Skills + /// page root, the exact fact + /// 's constructor now wires + /// (scrollbar.Model = list.Scroll). + /// + /// + /// Also pins Templates[0]'s (0x100002F4) own header- + /// caption child id (0x100002f6, DoSkillRecords @0x00481840's + /// own GetChildRecursive call) as prep evidence for whoever ports + /// the four-bucket sorted model (R2-4b, still open — see the AP-213 + /// row): live-DAT-measured as a , NOT a + /// — the SAME UIElement_Button-is- + /// DynamicCast(0xc)-compatible-with-UIElement_Text quirk + /// this campaign already ported for the six attribute-slider labels + /// (GF-4b) — retail's own DynamicCast(0xc) cast at + /// 0x00481855 would return null on a REAL Button object + /// otherwise, and the very next line unconditionally calls + /// UIElement_Text::SetStringInfo on it. A future port reads the + /// header caption through UiButton.Label, the same seam GF-4b + /// already established. + /// + /// + [InstalledDatFact] + public void SkillsPage_Listbox_HasAScrollbarLink_AndHeaderTemplateHasACaptionChild() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement skillsRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SkillsPageElementId)); + UiTemplateListBox list = Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003F7u)); + + Console.WriteLine( + $"[CC-Batch-F-DAT] Skills listbox ScrollbarElementId=0x{list.ScrollbarElementId:X8}"); + Assert.Equal(0x100003F8u, list.ScrollbarElementId); + Assert.IsType( + UiElement.FindDescendant(skillsRoot, list.ScrollbarElementId)); + + UiTemplateListEntry headerTemplate = list.Templates[0]; + Assert.Equal(0x100002F4u, headerTemplate.TemplateElementId); + UiElement? headerRow = LayoutImporter.Import( + dats, + headerTemplate.TemplateLayoutId, + headerTemplate.TemplateElementId, + _ => (0u, 0, 0), + null)?.Root; + UiElement realHeaderRow = Assert.IsAssignableFrom(headerRow); + Assert.IsType(UiElement.FindDescendant(realHeaderRow, 0x100002F6u)); + } + /// /// GF-15 (Campaign CC gate round 1, Batch A). Live-DAT-probe-confirmed /// during the investigation: the Message dialog catalog's popup diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 8790f9bb..089525d8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -24,6 +24,13 @@ public sealed class CharacterCreationUiControllerTests private const uint SkillTrainOnly = 1u; private const uint SkillSpecializable = 2u; + /// Review fix round F2 (Batch F): a "free" skill (trained + /// cost 0 in Aluvian's own per-heritage cost list) — pins + /// SetSkillText's bUntrainable re-derivation + /// (trainedCost != 0), which locks the down arrow at Trained for + /// exactly this shape. + private const uint SkillFreeTrained = 3u; + [Fact] public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage() { @@ -329,8 +336,9 @@ public sealed class CharacterCreationUiControllerTests .OnClick!(); IReadOnlyList rows = environment.SkillsList().ViewportForTest!.Children; - // Aluvian's fixture only costs SkillTrainOnly(1)/SkillSpecializable(2). - Assert.Equal(2, rows.Count); + // Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/ + // SkillFreeTrained(3, added for the F2 arrow-lock coverage below). + Assert.Equal(3, rows.Count); UiElement row = Assert.Single(rows, candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name @@ -341,11 +349,13 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal((SkillTrainOnly * 10u).ToString(), JoinedText(level)); // Default (never-touched) level: up cost = trained cost (2), down - // cost blank (nothing below Untrained/Inactive). + // cost = literal "0" (nothing below Untrained/Inactive, but + // SetSkillText @0x00480600's own Untrained branch writes a literal + // 0, unconditional — review fix round F1, Batch F). UiText upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u)); UiText downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u)); Assert.Equal("2", JoinedText(upCost)); - Assert.Equal(string.Empty, JoinedText(downCost)); + Assert.Equal("0", JoinedText(downCost)); // Advancing to Trained flips the cost pair: up = specCost-trainCost // (6-2=4), down = trainCost (2). FakeRuntime.SetSkillLevel is a @@ -361,6 +371,169 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal("2", JoinedText(downCost)); } + // ── Campaign CC gate round 1 Batch F: R2-4 + review F1/F2 ─────────── + + /// R2-4a: a plain row click selects the skill — the row's own + /// NAME text swaps to Vector4.One (the best-derived "brighter + /// white") and the info title + /// (ShowSkillsText @0x00481250's " (%d)" score suffix) + /// populates. Untrained/Inactive carries no bonus line, so the info + /// TEXT pane stays blank (the still-missing description/formula halves + /// — see 's own + /// doc). + [Fact] + public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + (UiDatElement row, UiText nameText) = environment.SkillRow(SkillTrainOnly); + Vector4 unselectedColor = nameText.DefaultColor; + + // Nothing selected yet. + Assert.Equal(string.Empty, JoinedText(environment.SkillInfoTitle())); + + row.OnClick!(); + + Assert.Equal(Vector4.One, nameText.DefaultColor); + Assert.NotEqual(unselectedColor, nameText.DefaultColor); + + // FakeRuntime.GetSkillScore's deterministic stand-in: skillId * 10. + string expectedTitle = + $"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})"; + Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle())); + Assert.Equal(string.Empty, JoinedText(environment.SkillInfoText())); + } + + /// R2-4a: retail re-selects the row after an arrow click too + /// (ListenToElementMessage @0x004814c0's own + /// SetSelectedItem(...,1) call following + /// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks + /// the level-gated bonus line as the skill advances (the TWO-space + /// literal "Training Bonus +5"/"Specialization Bonus +10", + /// matching the compiled string verbatim). + [Fact] + public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + (UiButton up, _) = environment.SkillRowArrows(SkillTrainOnly); + + up.OnClick!(); // Untrained/Inactive -> Trained. + BumpRevisionAndTick(environment); + Assert.Equal("Training Bonus +5", JoinedText(environment.SkillInfoText())); + + up.OnClick!(); // Trained -> Specialized. + BumpRevisionAndTick(environment); + Assert.Equal("Specialization Bonus +10", JoinedText(environment.SkillInfoText())); + } + + /// R2-4a: selecting a SECOND row restores the FIRST row's own + /// authored (unselected) color instead of leaving it stuck + /// highlighted. + [Fact] + public void SkillsPage_RowClick_DeselectsPreviousRow_RestoresItsOwnColor() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + (UiDatElement firstRow, UiText firstName) = environment.SkillRow(SkillTrainOnly); + Vector4 firstUnselected = firstName.DefaultColor; + (UiDatElement secondRow, UiText secondName) = environment.SkillRow(SkillSpecializable); + + firstRow.OnClick!(); + Assert.Equal(Vector4.One, firstName.DefaultColor); + + secondRow.OnClick!(); + Assert.Equal(Vector4.One, secondName.DefaultColor); + Assert.Equal(firstUnselected, firstName.DefaultColor); + } + + /// Review fix round F1: SetSkillText's Specialized + /// branch (@0x00480679) writes a literal "0" up-cost, + /// unconditional (nothing above Specialized needs the 999-blank gate), + /// and an UNCONDITIONAL down-cost — no gate even though this fixture's + /// value (4) happens to be well under 999. + [Fact] + public void SkillsPage_SpecializedCostText_UpCostIsLiteralZero_DownCostUnconditional() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + environment.Runtime.View.SetSkillLevel(SkillSpecializable, ChargenSkillAdvancementClass.Specialized); + BumpRevisionAndTick(environment); + + (UiElement row, _) = environment.SkillRow(SkillSpecializable); + UiText upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u)); + UiText downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u)); + + Assert.Equal("0", JoinedText(upCost)); + Assert.Equal("4", JoinedText(downCost)); // specCost(6) - trainCost(2). + } + + /// Review fix round F2: the Up/Down arrow Ghosted + /// (0x1000001a)/Enabled (0x1000001b) state pair — + /// Untrained's Down is ALWAYS ghosted (nothing below it); Up is gated + /// on remainingSkillCredits; a "free" skill (trained cost 0) + /// locks its OWN down arrow at Trained + /// (bUntrainable re-derived as trainedCost != 0) and + /// unlocks it again once Specialized (specialized cost is non-zero), + /// where its own Up arrow is then ALWAYS ghosted (nothing above + /// Specialized). + [Fact] + public void SkillsPage_ArrowStates_GatedOnCreditsAndFreeSkillLocksDownArrow() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + // Untrained/Inactive: Down ALWAYS ghosted; Up enabled (credits 50 + // >= trainedCost 2). + (UiButton up, UiButton down) = environment.SkillRowArrows(SkillTrainOnly); + Assert.Equal(0x1000001Bu, up.ActiveRetailStateId); + Assert.Equal(0x1000001Au, down.ActiveRetailStateId); + + up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled. + BumpRevisionAndTick(environment); + Assert.Equal(0x1000001Bu, down.ActiveRetailStateId); + + (UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained); + freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked. + BumpRevisionAndTick(environment); + Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId); + + freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks; + BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted. + Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId); + Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId); + } + + /// R2-4c: the listbox's own authored scrollbar link + /// () is wired to the + /// SAME listbox's model — the + /// ordinary page-level UiScrollbar.Model linkage, no widget + /// change. + [Fact] + public void SkillsPage_ListboxScrollbar_IsLinkedToTheListsOwnScroll() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + UiScrollbar scrollbar = environment.SkillsScrollbar(); + Assert.Same(environment.SkillsList().Scroll, scrollbar.Model); + } + [Fact] public void TownButton_SelectsTheLiteralStartAreaIndex() { @@ -1804,6 +1977,33 @@ public sealed class CharacterCreationUiControllerTests return (up, down); } + /// R2-4a (Batch F): locates a built skill row's ROOT + /// element (Templates[1]'s own UiDatElement) by its + /// name text, for row-CLICK (not arrow-click) selection tests. + /// Also returns the row's own name so a test + /// can assert its selection + /// highlight. + public (UiDatElement Row, UiText NameText) SkillRow(uint skillId) + { + string skillName = ItemAppraisalTextFormatter.SkillName((int)skillId); + UiElement row = Assert.Single( + SkillsList().ViewportForTest!.Children, + candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name + && JoinedText(name) == skillName); + UiDatElement datRow = Assert.IsType(row); + UiText nameText = Assert.IsType(UiElement.FindDescendant(row, 0x10000301u)); + return (datRow, nameText); + } + + public UiText SkillInfoTitle() => + Assert.IsType(Screen.FindElement(0x100003FBu)); + + public UiText SkillInfoText() => + Assert.IsType(Screen.FindElement(0x100003FCu)); + + public UiScrollbar SkillsScrollbar() => + Assert.IsType(Screen.FindElement(0x100003F8u)); + public UiTemplateListBox SummaryListBox() => Assert.IsType(Screen.FindElement(CharacterCreationSummaryPage.ListBoxId)); @@ -2171,6 +2371,7 @@ public sealed class CharacterCreationUiControllerTests { [SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6), [SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6), + [SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6), }; var aluvian = new ChargenHeritageOptions( @@ -2406,6 +2607,12 @@ public sealed class CharacterCreationUiControllerTests Y = 40f, Width = 300f, Height = 320f, + // R2-4c (Batch F): the listbox's own authored scrollbar link + // (dat property 0x72) — an arbitrary but plausible sibling id + // (retail's own "+1 from the listbox" convention, matching the + // hypothesis this batch's own investigation raised) since no + // live-DAT probe has pinned the real installed value yet. + ScrollbarElementId = 0x100003F8u, }; // GF-5 (2026-08-16): [0] is retail's own bucket-HEADER row // (0x100002F4, unused by this port's flat-list simplification); @@ -2415,6 +2622,7 @@ public sealed class CharacterCreationUiControllerTests list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002F4u)); list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100002FFu)); page.Children.Add(list); + page.Children.Add(ScrollbarInfo(0x100003F8u)); page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge page.Children.Add(TextInfo(0x100003FBu)); page.Children.Add(TextInfo(0x100003FCu)); @@ -2552,6 +2760,24 @@ public sealed class CharacterCreationUiControllerTests /// a bare button shape since this port's flat-list simplification never /// resolves it. /// + /// Review fix round F2 (Batch F): retail's own custom + /// Ghosted(0x1000001a)/Enabled(0x1000001b) state pair for + /// pSkillUpButton/pSkillDownButton — mirrors + /// CharacterCreationSkillsPage's own (private) + /// ArrowGhostedStateId/ArrowEnabledStateId consts so + /// TrySetRetailState has real, matching state descriptors to + /// resolve against (the raw-numeric-id lookup path, + /// UiButton.TryFindState) — the SAME "author arbitrary numeric + /// states directly" pattern already + /// uses for its own custom pair. + private static ElementInfo ArrowButtonInfo(uint id) + { + ElementInfo info = ButtonInfo(id); + info.States[0x1000001Au] = new UiStateInfo { Id = 0x1000001Au, Name = "ArrowGhosted" }; + info.States[0x1000001Bu] = new UiStateInfo { Id = 0x1000001Bu, Name = "ArrowEnabled" }; + return info; + } + private static UiElement BuildSkillRowTemplate(uint templateElementId) { if (templateElementId == 0x100002FFu) @@ -2563,12 +2789,19 @@ public sealed class CharacterCreationUiControllerTests Width = 280f, Height = 16f, }; + // R2-4a (Batch F): an explicit, distinguishable unselected + // color (retail's own gold list-caption tone, the same + // 218,167,85 GF-11b measured) so selection tests can tell + // CharacterCreationSkillsPage.SelectedNameColor (pure white) + // apart from a row's own authored default. + ElementInfo nameInfo = TextInfo(0x10000301u); + nameInfo.FontColor = new System.Numerics.Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f); row.Children.Add(ContainerInfo(0x10000300u)); // unreferenced icon/backdrop - row.Children.Add(TextInfo(0x10000301u)); // name + row.Children.Add(nameInfo); // name row.Children.Add(TextInfo(0x10000302u)); // pSkillLevelText row.Children.Add(TextInfo(0x10000303u)); // pUpCostText - row.Children.Add(ButtonInfo(0x10000304u)); // pSkillUpButton - row.Children.Add(ButtonInfo(0x10000305u)); // pSkillDownButton + row.Children.Add(ArrowButtonInfo(0x10000304u)); // pSkillUpButton + row.Children.Add(ArrowButtonInfo(0x10000305u)); // pSkillDownButton row.Children.Add(TextInfo(0x10000306u)); // pDownCostText return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root; } From e1d7d095993ae5f7e25c38e80d73527966821376 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 15:33:19 +0200 Subject: [PATCH 125/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20closeout=20=E2=80=94=20Group=201:=20real=20color=20?= =?UTF-8?q?wheel=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands Batch G's two STOPPED items, making the real palette-color swatch wheel visually live instead of inert: - UiButton and UiDatElement gain a per-instance Tint property threaded into every existing DrawSprite call (defaults to Vector4.One, so every pre-existing button/element is byte-identical unless a caller sets a non-identity tint). - CharacterCreationAppearancePage now sets Tint directly on each color swatch button and the GradCircle element, replacing the Batch G flat-fill ChargenSwatchColorTile overlay outright — an opaque rectangle drawn on top can never reproduce retail's actual SurfaceWindow::BlitAndColor(..., Blit_Multiply, color) multiply blend, only a genuine per-instance sprite tint can, so the overlay approach is deleted rather than layered under the new mechanism. - CharacterCreationUiController and RetailUiRuntime grow pass-through properties (AppearancePalSetSource/AppearanceClothingTableSource/ AppearancePaletteColorSource) mirroring the existing PreviewControl seam, so LivePresentationComposition can wire a DAT-backed ChargenAppearanceCatalog into the Appearance page (wiring itself lands with the Group 3 commit, since it shares a file with an unrelated F16 fix). Register: AP-216/AP-217 RETIRED (161 -> now further reduced in later commits) — both rows' remaining gaps are closed, not merely narrowed. CharacterCreationAppearancePageSwatchColorTests updated for the new Tint-based assertions (two pre-existing assertions were carried over incorrectly from the old overlay-visibility model and are corrected). Co-Authored-By: Claude Fable 5 --- .../Layout/CharacterCreationAppearancePage.cs | 125 ++++++++---------- .../Layout/CharacterCreationUiController.cs | 23 ++++ .../UI/Layout/ChargenSwatchColorTile.cs | 69 ---------- src/AcDream.App/UI/Layout/UiDatElement.cs | 13 +- src/AcDream.App/UI/RetailUiRuntime.cs | 35 +++++ src/AcDream.App/UI/UiButton.cs | 19 ++- ...rCreationAppearancePageSwatchColorTests.cs | 101 +++++++------- 7 files changed, 193 insertions(+), 192 deletions(-) delete mode 100644 src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 813f242f..cadde2ae 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -73,20 +73,29 @@ namespace AcDream.App.UI.Layout; /// /// /// The real color wheel (Campaign CC gate round 1 Batch G, R2-5, -/// register AP-216/AP-217): retail's DoColorSpots @0x0047d850 / -/// DoGradDisk @0x0047da90 paint each swatch and the gradient disc -/// with an ACTUAL representative color sampled from the real DAT palette -/// data (AcDream.Core.CharGen.ChargenSwatchColorResolver ports the +/// register AP-216/AP-217 — CLOSEOUT (Group 1) makes it visually live): +/// retail's DoColorSpots @0x0047d850 / DoGradDisk @0x0047da90 +/// paint each swatch and the gradient disc with an ACTUAL representative +/// color sampled from the real DAT palette data +/// (AcDream.Core.CharGen.ChargenSwatchColorResolver ports the /// computation — see its own doc for the two color-source shapes and the -/// clothing PalSet lookup). / -/// / are -/// late-bound composition seams (same pattern as ) -/// a DAT-backed catalog wires in after construction; the -/// children painted over each swatch/the gradient disc are this batch's -/// rendering primitive — see that class's own doc for why it is a flat -/// color fill (a documented approximation of retail's actual recolored- -/// sprite blit) and the STOPPED shared-file edit that would upgrade it to -/// a genuine texture tint. +/// clothing PalSet lookup) via a genuine multiplicative sprite tint — +/// retail's own SurfaceWindow::BlitAndColor(..., Blit_Multiply, +/// color). // +/// are late-bound composition seams (same +/// pattern as ) a DAT-backed catalog wires in +/// after construction (CharacterCreationUiController.AppearancePalSetSource +/// etc., assigned once by LivePresentationComposition alongside the +/// existing AppearancePreviewControl wiring). Each swatch +/// () and the gradient disc (, +/// resolved via ) now set their OWN +/// / directly — +/// the earlier flat-fill ChargenSwatchColorTile overlay (Batch G's +/// documented approximation, since neither widget exposed a tint hook yet) +/// is retired: a flat opaque rectangle drawn ON TOP of a sprite can never +/// reproduce a multiply blend, only a genuine per-instance sprite tint can, +/// so this closeout replaces the overlay outright rather than layering a +/// tint UNDER it. /// /// internal sealed class CharacterCreationAppearancePage : IDisposable @@ -193,17 +202,13 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiButton? _rotateCounterClockwise; private readonly UiButton? _zoomIn; private readonly UiButton? _zoomOut; - private readonly UiElement? _gradCircle; - /// R2-5: one flat-color tile per swatch, added as an EXTRA - /// child of the swatch it decorates (see 's - /// own doc) — null wherever the matching entry - /// itself is null (nothing to attach to). - private readonly ChargenSwatchColorTile?[] _swatchColorTiles = new ChargenSwatchColorTile?[SwatchIds.Length]; - - /// R2-5: the gradient disc's own tint tile, an extra child of - /// . - private readonly ChargenSwatchColorTile? _gradCircleTile; + /// The gradient disc (0x1000030e) — Type 3 in the + /// authored dat, so (not the base + /// ) is what resolves; + /// typed concretely (post-closeout Group 1) so + /// can set directly. + private readonly UiDatElement? _gradCircle; private Choice _currentChoice = Choice.Face; private Part _currentPart = Part.Hair; @@ -219,15 +224,13 @@ internal sealed class CharacterCreationAppearancePage : IDisposable /// R2-5 late-bound seams (same pattern as /// above) for the real color-wheel mechanism — null (the default) /// leaves every swatch/the gradient disc showing ONLY its authored - /// static art, i.e. this page's pre-Batch-G behavior, until a - /// composition root supplies a DAT-backed - /// AcDream.Content.CharGen.ChargenAppearanceCatalog (which - /// already implements all three interfaces) for these three - /// properties, mirroring how itself gets - /// wired in from outside this class. STOPPED (Batch G): that - /// assignment is a 3-line addition to - /// CharacterCreationUiController.cs, outside this batch's file - /// contract — see the batch's handoff notes. + /// static art, i.e. this page's pre-Batch-G behavior. Closeout Group 1 + /// wires a DAT-backed AcDream.Content.CharGen.ChargenAppearanceCatalog + /// (which already implements all three interfaces) into these three + /// properties via CharacterCreationUiController.AppearancePalSetSource/ + /// AppearanceClothingTableSource/AppearancePaletteColorSource, + /// mirroring how itself gets wired in from + /// outside this class. /// internal IChargenPalSetSource? PalSetSource { get; set; } internal IChargenClothingTableSource? ClothingTableSource { get; set; } @@ -280,18 +283,6 @@ internal sealed class CharacterCreationAppearancePage : IDisposable int index = i; swatch.OnClick = () => SelectColor(index); _swatches[i] = swatch; - - // R2-5: an extra CHILD tile, sized to exactly cover the - // swatch's own face — see ChargenSwatchColorTile's own doc for - // why this is a flat fill rather than a recolored sprite, and - // for why ClickThrough there keeps this from ever swallowing - // the swatch's own click. - var tile = new ChargenSwatchColorTile - { - Left = 0f, Top = 0f, Width = swatch.Width, Height = swatch.Height, - }; - swatch.AddChild(tile); - _swatchColorTiles[i] = tile; } for (int i = 0; i < SwatchOverlayIds.Length; i++) @@ -301,15 +292,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable if (_shadeScroll is not null) _shadeScroll.ScalarChanged = SetShadeFromScalar; - _gradCircle = Find(pageRoot, GradCircleId); - if (_gradCircle is not null) - { - _gradCircleTile = new ChargenSwatchColorTile - { - Left = 0f, Top = 0f, Width = _gradCircle.Width, Height = _gradCircle.Height, - }; - _gradCircle.AddChild(_gradCircleTile); - } + _gradCircle = Find(pageRoot, GradCircleId); Viewport = Find(pageRoot, ViewportId); @@ -777,12 +760,13 @@ internal sealed class CharacterCreationAppearancePage : IDisposable continue; bool visible = i < displayCount; swatch.Visible = visible; - if (_swatchColorTiles[i] is { } tile) - { - ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null; - tile.Color = rgb is { } c ? ToTintColor(c) : null; - tile.Visible = rgb is not null; - } + // Closeout Group 1: a genuine multiplicative sprite tint on the + // swatch's own authored spot art (UiButton.Tint), replacing the + // Batch G flat-fill overlay. Vector4.One (identity) reproduces + // the swatch's bare authored art untouched — both "no color data + // yet" (sources unwired) and "beyond this part's color count". + ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null; + swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One; } // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL): @@ -798,18 +782,17 @@ internal sealed class CharacterCreationAppearancePage : IDisposable { bool isEyes = _currentPart == Part.Eyes; _gradCircle.Visible = !isEyes; - if (_gradCircleTile is { } gradTile) - { - int gradIndex = isEyes - ? -1 - : colorSlot is null - ? 0 - : (int)ColorCurrent(_currentPart, snapshot.Appearance); - ChargenSwatchRgb? gradColor = - gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null; - gradTile.Color = gradColor is { } gc ? ToTintColor(gc) : null; - gradTile.Visible = !isEyes && gradColor is not null; - } + // Closeout Group 1: same Tint mechanism as the swatches above — + // the gradient disc's own authored art is multiplied by the + // currently-selected swatch's color instead of an overlay child. + int gradIndex = isEyes + ? -1 + : colorSlot is null + ? 0 + : (int)ColorCurrent(_currentPart, snapshot.Appearance); + ChargenSwatchRgb? gradColor = + gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null; + _gradCircle.Tint = gradColor is { } gc ? ToTintColor(gc) : Vector4.One; } ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index d736bf57..b728e6ee 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -363,6 +363,29 @@ internal sealed class CharacterCreationUiController : IDisposable set => _appearancePage.PreviewControl = value; } + /// Campaign CC gate round 1 closeout (Group 1, R2-5): the same + /// late-bound pattern as above, + /// for the real color-wheel/swatch-color mechanism's three DAT-backed + /// seams — see 's + /// own doc comment. + internal IChargenPalSetSource? AppearancePalSetSource + { + get => _appearancePage.PalSetSource; + set => _appearancePage.PalSetSource = value; + } + + internal IChargenClothingTableSource? AppearanceClothingTableSource + { + get => _appearancePage.ClothingTableSource; + set => _appearancePage.ClothingTableSource = value; + } + + internal IChargenPaletteColorSource? AppearancePaletteColorSource + { + get => _appearancePage.PaletteColorSource; + set => _appearancePage.PaletteColorSource = value; + } + /// Gates the Appearance preview's per-frame work on whether /// that specific page — AND the whole chargen screen — is the one /// currently showing. Close() only ever hides , diff --git a/src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs b/src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs deleted file mode 100644 index 069ad825..00000000 --- a/src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI.Layout; - -/// -/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): paints -/// one flat-fill patch of a computed -/// on top of whatever element it is attached to as a child — the -/// Appearance page's real-color rendering primitive for the nine color -/// swatches and the gradient disc. -/// -/// -/// Why a flat fill, not a recolored sprite (documented approximation): -/// retail's own mechanism (gmCGAppearancePage::DoColorSpots @0x0047d850 -/// / DoGradDisk @0x0047da90) blits an authored "spot"/gradient -/// graphic and RECOLORS it in place -/// (SurfaceWindow::ReplaceColor / BlitAndColor(..., -/// Blit_Multiply, color)) — a genuine multiplicative texture tint. The -/// retained-UI sprite pipeline this codebase already has -/// () DOES carry a per-draw -/// Vector4 tint parameter that could reproduce that exact multiply -/// blend, but neither (the nine swatches' own type, -/// sealed) nor (the gradient disc's own type) -/// exposes a per-instance tint hook on their EXISTING sprite draw calls — -/// adding one is a small, precisely-scoped, additive change to those two -/// shared widget files, outside this batch's file contract (reported as a -/// STOPPED item; see the batch's own commit message / handoff notes for the -/// exact diff). Rather than leave the swatches/wheel colorless pending that -/// follow-up, this class achieves the same OBSERVABLE result — "this -/// swatch/wheel visibly reflects the real computed color" — the cheapest -/// way the CURRENT public primitives allow: -/// is a plain solid-color quad, so the tile reads as a flat color patch -/// rather than a recolored dot/gradient graphic. It is added as an extra -/// CHILD of the swatch/disc it decorates (never replacing or subclassing -/// either sealed/shared type), so it draws strictly ON TOP -/// (: children paint after their -/// parent's own OnDraw) without disturbing the underlying element's -/// own state machine, media, or click handling at all. -/// -/// -/// -/// defaults to false on the -/// base class, so this MUST be set true by the constructor here (not left -/// to a caller to remember) — walks -/// children BEFORE testing the parent, and an opaque, click-absorbing tile -/// sitting on top of a swatch button would silently eat every click meant -/// for it. -/// -/// -internal sealed class ChargenSwatchColorTile : UiElement -{ - public ChargenSwatchColorTile() - { - ClickThrough = true; - Visible = false; - } - - /// The color to paint, or null to draw nothing this frame - /// ( is the authoritative on/off switch — callers - /// should set both together, matching every other swatch-visibility - /// site in CharacterCreationAppearancePage). - public Vector4? Color { get; set; } - - protected override void OnDraw(UiRenderContext ctx) - { - if (Color is { } c && Width > 0f && Height > 0f) - ctx.DrawFill(0f, 0f, Width, Height, c); - } -} diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index a132e6b1..7e70a5d6 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -179,6 +179,15 @@ public class UiDatElement : UiElement, IUiDatStateful /// Label color (default white). public Vector4 LabelColor { get; set; } = Vector4.One; + /// + /// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance + /// multiplicative sprite tint, threaded into both + /// calls this class makes (the runtime-image path and the ordinary + /// authored-media path) — same shape and same default-identity + /// no-op-for-existing-callers guarantee as . + /// + public Vector4 Tint { get; set; } = Vector4.One; + /// Retail LayoutDesc property 0x21 (two-pass glyph outline, /// UIElement_Text::SetOutline @0x0046a81c). Seeded in the ctor from the /// element's effective-default state, same as @@ -271,7 +280,7 @@ public class UiDatElement : UiElement, IUiDatStateful 0f, 1f, 1f, - Vector4.One); + Tint); } DrawLabel(ctx); return; @@ -290,7 +299,7 @@ public class UiDatElement : UiElement, IUiDatStateful // doc). Overlay/Alphablend use the same blit (the sprite shader // already alpha-blends). No Stretch mode exists in DrawModeType; // whole-canvas stretching happens at UiRoot.FixedCanvasSize. - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Tint); } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index a62fb663..a6f56b47 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -680,6 +680,41 @@ public sealed class RetailUiRuntime : IDisposable } } + /// Campaign CC gate round 1 closeout (Group 1, R2-5): the same + /// late-bound pattern as above, for + /// the real color-wheel/swatch-color mechanism's three DAT-backed seams + /// — see 's + /// own doc comment. + internal AcDream.Core.CharGen.IChargenPalSetSource? ChargenPalSetSource + { + get => CharacterCreationController?.AppearancePalSetSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearancePalSetSource = value; + } + } + + internal AcDream.Core.CharGen.IChargenClothingTableSource? ChargenClothingTableSource + { + get => CharacterCreationController?.AppearanceClothingTableSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearanceClothingTableSource = value; + } + } + + internal AcDream.Core.CharGen.IChargenPaletteColorSource? ChargenPaletteColorSource + { + get => CharacterCreationController?.AppearancePaletteColorSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearancePaletteColorSource = value; + } + } + /// CC6b-MOUNT: whether the Appearance page (specifically) is /// the one currently showing — false, safely, before the screen mounts. /// diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index e266710d..bf574e4b 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -153,6 +153,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// public uint? FaceFileOverride { get; set; } + /// + /// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance + /// multiplicative sprite tint, threaded into every + /// call this class makes (main face, face-segment, drag-acceptance + /// overlay) — retail's own SurfaceWindow::BlitAndColor(..., + /// Blit_Multiply, color). Default (white, + /// full alpha) leaves every DrawSprite call byte-identical to before + /// this property existed; only a caller that explicitly sets a + /// non-identity tint (e.g. 's + /// color-wheel swatches) changes what draws. + /// + public Vector4 Tint { get; set; } = Vector4.One; + /// Additional left inset for left-aligned labels. public float LabelOffsetX { get; set; } = 3f; @@ -469,7 +482,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful float faceWidth = FaceWidth > 0f ? FaceWidth : Width; float faceHeight = FaceHeight > 0f ? FaceHeight : Height; ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight, - 0, 0, faceWidth / tw, faceHeight / th, Vector4.One); + 0, 0, faceWidth / tw, faceHeight / th, Tint); } } } @@ -525,7 +538,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful { var (tex, _, _) = _resolve(dragSprite); if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint); } } @@ -647,7 +660,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful // first reflowed by its own four-edge retail layout policy. ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height, 0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight, - Vector4.One); + Tint); } private void AddAvailableStates(ElementInfo mediaInfo) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs index b3392e80..fc81db24 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs @@ -234,12 +234,9 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests Assert.IsType(UiElement.FindDescendant( pageRoot, CharacterCreationAppearancePage.SwatchIds[index])); - private static ChargenSwatchColorTile SwatchTile(UiElement pageRoot, int index) => - Assert.IsType(Assert.Single(Swatch(pageRoot, index).Children)); - - private static ChargenSwatchColorTile GradTile(UiElement pageRoot) => - Assert.IsType(Assert.Single( - UiElement.FindDescendant(pageRoot, CharacterCreationAppearancePage.GradCircleId)!.Children)); + private static UiDatElement GradCircle(UiElement pageRoot) => + Assert.IsType(UiElement.FindDescendant( + pageRoot, CharacterCreationAppearancePage.GradCircleId)); private static Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f); @@ -251,13 +248,13 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests page.Refresh(view, view.Snapshot); - Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); - Assert.True(SwatchTile(root, 0).Visible); - Assert.Equal(ToVector4(HairColorB), SwatchTile(root, 1).Color); - Assert.True(SwatchTile(root, 1).Visible); - // Only two hair colors exist — swatch 2 must be blank. - Assert.Null(SwatchTile(root, 2).Color); - Assert.False(SwatchTile(root, 2).Visible); + Assert.Equal(ToVector4(HairColorA), Swatch(root, 0).Tint); + Assert.True(Swatch(root, 0).Visible); + Assert.Equal(ToVector4(HairColorB), Swatch(root, 1).Tint); + Assert.True(Swatch(root, 1).Visible); + // Only two hair colors exist — swatch 2 must be hidden and untinted. + Assert.Equal(Vector4.One, Swatch(root, 2).Tint); + Assert.False(Swatch(root, 2).Visible); } /// Part change (Hair -> Eyes via the spin's own select-zone @@ -269,14 +266,14 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests var (pal, clothing, colors) = MakeSources(); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); page.Refresh(view, view.Snapshot); - Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); + Assert.Equal(ToVector4(HairColorA), Swatch(root, 0).Tint); UiButton eyesSpin = Assert.IsType( UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId)); eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change. - Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color); - Assert.Equal(ToVector4(EyeColorB), SwatchTile(root, 1).Color); + Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint); + Assert.Equal(ToVector4(EyeColorB), Swatch(root, 1).Tint); } /// Color change (clicking a different swatch) must retint the @@ -287,9 +284,12 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests var (pal, clothing, colors) = MakeSources(); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); page.Refresh(view, view.Snapshot); - // No color selected yet (Unset) — no tint. - Assert.Null(GradTile(root).Color); - Assert.False(GradTile(root).Visible); + // No color selected yet (Unset) — no tint, but the disc's own base + // art is still shown (Visible tracks !isEyes only, independent of + // whether a real color has been resolved — the disc is never + // hidden pending a tint, only Eyes hides it at all). + Assert.Equal(Vector4.One, GradCircle(root).Tint); + Assert.True(GradCircle(root).Visible); Swatch(root, 0).OnClick!(); view.Snapshot = view.Snapshot with @@ -297,8 +297,8 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests Appearance = view.Snapshot.Appearance with { HairColor = 0u }, }; page.Refresh(view, view.Snapshot); - Assert.Equal(ToVector4(HairColorA), GradTile(root).Color); - Assert.True(GradTile(root).Visible); + Assert.Equal(ToVector4(HairColorA), GradCircle(root).Tint); + Assert.True(GradCircle(root).Visible); Swatch(root, 1).OnClick!(); view.Snapshot = view.Snapshot with @@ -306,13 +306,13 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests Appearance = view.Snapshot.Appearance with { HairColor = 1u }, }; page.Refresh(view, view.Snapshot); - Assert.Equal(ToVector4(HairColorB), GradTile(root).Color); + Assert.Equal(ToVector4(HairColorB), GradCircle(root).Tint); } - /// AP-217: Eyes always blanks the gradient disc's tile — no - /// tint is ever shown for Eyes, regardless of the selected eye color. + /// AP-217: Eyes always hides the gradient disc — no tint is + /// ever shown for Eyes, regardless of the selected eye color. [Fact] - public void EyesPart_GradientDiscTileStaysBlank() + public void EyesPart_GradientDiscStaysHiddenAndUntinted() { var (pal, clothing, colors) = MakeSources(); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); @@ -326,11 +326,11 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId)); eyesSpin.OnClickAt!(180, 10); - Assert.False(GradTile(root).Visible); - Assert.Null(GradTile(root).Color); + Assert.False(GradCircle(root).Visible); + Assert.Equal(Vector4.One, GradCircle(root).Tint); // The swatches themselves still show real eye colors — only the - // disc blanks. - Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color); + // disc hides. + Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint); } /// Nose/Mouth/Skin (colorSlot == null): retail still shows @@ -348,19 +348,20 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId)); skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it. - Assert.Equal(ToVector4(SkinColor), SwatchTile(root, 0).Color); - Assert.True(SwatchTile(root, 0).Visible); - Assert.Null(SwatchTile(root, 1).Color); - Assert.False(SwatchTile(root, 1).Visible); - Assert.Equal(ToVector4(SkinColor), GradTile(root).Color); - Assert.True(GradTile(root).Visible); + Assert.Equal(ToVector4(SkinColor), Swatch(root, 0).Tint); + Assert.True(Swatch(root, 0).Visible); + Assert.Equal(Vector4.One, Swatch(root, 1).Tint); + Assert.False(Swatch(root, 1).Visible); + Assert.Equal(ToVector4(SkinColor), GradCircle(root).Tint); + Assert.True(GradCircle(root).Visible); } /// Headgear's swatch resolves through the CURRENTLY EQUIPPED /// garment's own ClothingTable — an Unset headgear style (no garment) - /// shows no swatches at all. + /// shows no color (the swatch stays visible with its bare authored art, + /// untinted). [Fact] - public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches() + public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoColor() { var (pal, clothing, colors) = MakeSources(); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); @@ -374,16 +375,21 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId)); headgearSpin.OnClickAt!(180, 10); - Assert.Equal(ToVector4(HeadgearColorA), SwatchTile(root, 0).Color); + Assert.Equal(ToVector4(HeadgearColorA), Swatch(root, 0).Tint); // Now un-equip (Unset) and refresh again — no garment, no colors. + // The swatch's own Visible stays true (the "beyond count" gate is + // the gender's fixed ClothingColors list length, independent of + // which garment is equipped — Batch C's already-shipped half); only + // the TINT reverts to identity, showing the swatch's bare authored + // art with no color. view.Snapshot = view.Snapshot with { Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset }, }; page.Refresh(view, view.Snapshot); - Assert.Null(SwatchTile(root, 0).Color); - Assert.False(SwatchTile(root, 0).Visible); + Assert.Equal(Vector4.One, Swatch(root, 0).Tint); + Assert.True(Swatch(root, 0).Visible); } /// Heritage/gender change must re-source the color computation @@ -395,7 +401,7 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests var (pal, clothing, colors) = MakeSources(); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); page.Refresh(view, view.Snapshot); - Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color); + Assert.Equal(ToVector4(HairColorA), Swatch(root, 0).Tint); // A second heritage with a DIFFERENT hair-color list. const uint otherHairPalSetId = 0x0F00_00AAu; @@ -409,16 +415,17 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests page.Refresh(otherView, otherView.Snapshot); - Assert.Equal(ToVector4(otherHairColor), SwatchTile(root, 0).Color); + Assert.Equal(ToVector4(otherHairColor), Swatch(root, 0).Tint); } /// Before / /// / /// are - /// wired (composition-root STOPPED item), every tile MUST stay - /// invisible — the mechanism is fully inert, not a half-broken draw. + /// wired, every swatch/the gradient disc MUST show its bare authored + /// art (identity tint) — the mechanism is fully inert, not a + /// half-broken draw. [Fact] - public void UnwiredSources_LeaveEveryTileInvisible() + public void UnwiredSources_LeaveEverySwatchUntinted() { var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId); var bindings = new CharacterCreationRuntimeBindings( @@ -440,8 +447,8 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests page.Refresh(view, view.Snapshot); - Assert.False(GradTile(root).Visible); + Assert.Equal(Vector4.One, GradCircle(root).Tint); for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++) - Assert.False(SwatchTile(root, i).Visible); + Assert.Equal(Vector4.One, Swatch(root, i).Tint); } } From 0fed5fdd914c4ab79235685bb0e1f8ef439a2da1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 15:33:39 +0200 Subject: [PATCH 126/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20closeout=20=E2=80=94=20Group=202:=20Skills=20page?= =?UTF-8?q?=20four-bucket=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the last remaining half of retail's Skills page: the four-bucket sorted skill list (Specialized/Trained/UseableUntrained/UnuseableUntrained, UpdateSkillEntry's own iMinlevel <= 1 test), plus the info box's description + formula completion. - ChargenSkillDetail/ChargenSkillFormula (Core) thread SkillBase.MinLevel/ Description/Formula from the global SkillTable, exposed via a new ChargenOptions.TryGetSkillDetail (nullable-with-default parameter, so every pre-existing ChargenOptions call site compiles unchanged). ChargenTableReader.Project populates it from the same SkillTable loop that already builds GlobalSkillCostsBySkillId. - CharacterCreationSkillsPage.RebuildRows now groups every costable skill into SkillBucket, sorts each bucket alphabetically by name (InsertEntrySorted's wcscmp, ported as string.CompareOrdinal), and builds one Templates[0] header row per bucket ahead of that bucket's Templates[1] skill rows — DoSkillRecords' own unconditional 4-header-then-populate order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild with the current selection explicitly preserved). - RefreshInfoBox now composes description (word-wrapped via DatRichText.Compose) + the level-gated bonus line (an exact, unwrapped literal — NOT routed through word-wrap, which would have collapsed its authored double-space formatting) + ComposeFormula's "Formula : ..." line (MakeSkillFormula ported with high confidence for the prefix/ per-attribute-term/divisor/bonus-suffix shape; the two-attribute connector text is a disclosed approximation, register AP-231, since the decompiled function's own connector literals could not be recovered byte-exact by this session's static-only tooling). Register: AP-213 RETIRED (160 active rows). Live-DAT gate: the installed SkillTable's MinLevel distribution matches the investigation's own recorded finding exactly (38 entries, 23 useable-untrained / 15 trained-required). 3 new fixture tests + 1 new live-DAT test; 3 pre-existing integration tests fixed (they captured row widget references before a bucket-changing click, which now rebuilds and discards those references — a real, correct consequence of the new model, not a bug). Co-Authored-By: Claude Fable 5 --- .../UI/Layout/CharacterCreationSkillsPage.cs | 479 ++++++++++++++---- .../CharGen/ChargenTableReader.cs | 23 +- src/AcDream.Core/CharGen/ChargenOptions.cs | 22 +- .../CharGen/ChargenSkillAdvancement.cs | 47 ++ .../CharacterCreationUiControllerTests.cs | 214 +++++++- .../ChargenTableReaderInstalledDatTests.cs | 48 ++ 6 files changed, 712 insertions(+), 121 deletions(-) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index e7e0c231..8531ddac 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Numerics; +using System.Text; using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -7,11 +8,12 @@ using AcDream.Runtime.Session; namespace AcDream.App.UI.Layout; /// -/// The Skills page (gmCGSkillsPage, root 0x100003d3) — -/// simplified to one flat listbox rather than retail's four-bucket sorted -/// insertion model (InsertEntrySorted/UpdateSkillEntry, -/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register -/// AP-213). Decomp +/// The Skills page (gmCGSkillsPage, root 0x100003d3) — now +/// ported to retail's four-bucket sorted insertion model +/// (InsertEntrySorted/UpdateSkillEntry, Specialized/Trained/ +/// UseableUntrained/UnuseableUntrained, register AP-213 CLOSED at the +/// Campaign CC gate round 1 closeout Group 2 — see the closeout paragraph +/// below). Decomp /// anchors: gmCGSkillsPage::InitializePage @ 0x00481dd0 (listbox /// 0x100003f7, credits meter 0x100002f3 — imports as button /// 0x100003f9's own consumed Label, see the ctor comment — info @@ -51,19 +53,14 @@ namespace AcDream.App.UI.Layout; /// DecreaseSkillLevel, same dispatcher's case 0x10000305). /// Both buttons fire on a PLAIN click, not click-vs-double-click on one /// shared row — the row now wires exactly that, retiring AP-213's own -/// click-to-advance/double-click-retreat single-button substitution (the -/// row's still-simplified flat-list-vs-four-bucket half is untouched and -/// stays registered). +/// click-to-advance/double-click-retreat single-button substitution. /// /// /// /// Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review /// F1/F2): four of R2-4's five sub-items are fixed here; the -/// four-bucket sorted model (R2-4b) is NOT — see the batch report and the -/// AP-213 row for the exact missing data channel (retail's Useable-vs- -/// Unuseable-Untrained split reads SkillBase.MinLevel, which -/// / -/// do not carry today). +/// four-bucket sorted model (R2-4b) was NOT — see the closeout paragraph +/// below for where it lands. /// /// R2-4a (row selection): a row click (or an arrow click, matching /// retail's own post-Increase/DecreaseSkillLevel SetSelectedItem(..., @@ -72,11 +69,8 @@ namespace AcDream.App.UI.Layout; /// user's own report + the GF-11b precedent) and the info panes /// (0x100003fb/0x100003fc) get ShowSkillsText /// @0x00481250's title (name + score, " (%d)\n") and bonus line -/// ("Training Bonus +5"/"Specialization Bonus +10") — a -/// PARTIAL port: the description (SkillBase._description) and -/// MakeSkillFormula @0x00480e10's computed formula text are not -/// reachable from this page's current data surface; see -/// 's own doc. +/// ("Training Bonus +5"/"Specialization Bonus +10") — see the +/// closeout paragraph below for the description/formula completion. /// R2-4c (scrollbar): the listbox's own authored scrollbar link /// (, dat /// property 0x72) is now wired to @@ -108,9 +102,84 @@ namespace AcDream.App.UI.Layout; /// — no new data needed. /// /// +/// +/// +/// Campaign CC gate round 1 closeout (Group 2, 2026-08-16) — AP-213 +/// CLOSED, R2-4b implemented: +/// threads SkillBase.MinLevel/Description/Formula from +/// the global SkillTable through +/// (Content's ChargenTableReader.Project populates it — these three +/// fields have NO per-heritage override in retail, unlike costs). Row +/// building now groups every costable skill into +/// (Specialized/Trained/UseableUntrained/UnuseableUntrained, +/// UpdateSkillEntry's own iMinlevel <= 1 useable-vs- +/// unuseable-untrained test) and sorts each bucket's rows alphabetically by +/// name (InsertEntrySorted's wcscmp compare, ported as +/// string.CompareOrdinal), inserting one Templates[0] header +/// row per bucket (caption child 0x100002f6, a +/// per the same UIElement_Button-is-DynamicCast(0xc)-compatible- +/// with-Text quirk GF-4b already used) ahead of that bucket's own +/// Templates[1] skill rows — matching DoSkillRecords's own +/// unconditional 4-header-then-populate build order exactly. Headers are +/// ALWAYS built, even for an empty bucket, matching retail (no bucket ever +/// disappears just because it has zero rows this round). Advancing/ +/// retreating a skill moves its row between buckets: +/// detects a bucket change per-row (cheap: +/// against each row's OWN cached ) rather than +/// reproducing retail's incremental InsertEntrySorted single-row +/// move — a full achieves the SAME observable +/// bucket/sort placement every tick a change is detected, with the current +/// selection explicitly preserved across that rebuild (unlike a heritage +/// change, which clears it, matching retail's own roster invalidation). +/// 's own doc covers the description/formula +/// completion. +/// /// internal sealed class CharacterCreationSkillsPage : IDisposable { + /// Retail's four skill buckets, in DoSkillRecords's own + /// build order (Specialized/Trained/UseableUntrained/UnuseableUntrained + /// — top to bottom in the listbox). + private enum SkillBucket + { + Specialized, + Trained, + UseableUntrained, + UnuseableUntrained, + } + + /// Bucket header row string-table keys, in + /// order — DoSkillRecords' + /// compute_str_hash calls (ID_CharGen_Specialized etc.). + private static readonly (SkillBucket Bucket, string StringKey)[] BucketOrder = + [ + (SkillBucket.Specialized, "ID_CharGen_Specialized"), + (SkillBucket.Trained, "ID_CharGen_Trained"), + (SkillBucket.UseableUntrained, "ID_CharGen_UseableUntrained"), + (SkillBucket.UnuseableUntrained, "ID_CharGen_UnuseableUntrained"), + ]; + + /// UpdateSkillEntry @0x00480bf0's own bucket test: + /// Specialized(3)/Trained(2) map directly; Untrained/Inactive (every + /// other value — Inactive is + /// unreachable for any row this page ever lists, since every listed + /// skill is costable and RuntimeCharacterCreationState.ResetSkillLevelsLocked + /// always seeds a costable skill's slot at Untrained-or-better, kept + /// here only for the same defensive completeness as retail's own + /// switch) split on iMinlevel <= 1. + private static SkillBucket ComputeBucket(ChargenSkillAdvancementClass level, uint minLevel) => level switch + { + ChargenSkillAdvancementClass.Specialized => SkillBucket.Specialized, + ChargenSkillAdvancementClass.Trained => SkillBucket.Trained, + _ => minLevel <= 1 ? SkillBucket.UseableUntrained : SkillBucket.UnuseableUntrained, + }; + + /// Retail's own bucket-header caption child + /// (0x100002f6, live-DAT-measured as a — + /// the same UIElement_Button-is-Text-compatible quirk GF-4b + /// already ported). + private const uint HeaderCaptionElementId = 0x100002F6u; + /// Retail's own row-name id (set once at row build; retail /// never re-writes it on refresh either — DoSkillRecords' /// UIElement_Text::SetText(id_2, &var_138) at @@ -165,10 +234,15 @@ internal sealed class CharacterCreationSkillsPage : IDisposable /// every tick, resolved once at build time rather than re-walked per /// refresh. is the row's OWN authored /// (DAT-default) name color, captured at build time so R2-4a's - /// selection highlight can restore it exactly on deselect. + /// selection highlight can restore it exactly on deselect. + /// is the bucket this row was LAST built into — + /// compares it against a fresh + /// call every tick to detect an + /// advance/retreat that needs a re-bucket. private readonly record struct SkillRow( UiElement Root, uint SkillId, + SkillBucket Bucket, UiText? NameText, UiText? LevelText, UiText? UpCostText, @@ -237,11 +311,32 @@ internal sealed class CharacterCreationSkillsPage : IDisposable IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) { - if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId) + bool heritageChanged = !_rowsBuilt || _lastHeritageId != snapshot.HeritageId; + // Group 2 closeout: an advance/retreat can move a row into a + // different bucket (UpdateSkillEntry's own re-bucket-on-level- + // change) — detect that cheaply against each row's own cached + // Bucket before paying for a full rebuild. + bool bucketsChanged = !heritageChanged && AnyRowBucketChanged(view); + if (heritageChanged || bucketsChanged) { + // Only a HERITAGE change invalidates the current selection + // (retail's own roster-replace semantics) — a bucket move keeps + // the same skill selected, just relocated within the list. + uint? preservedSkillId = heritageChanged ? null : _selectedSkillId; RebuildRows(view, snapshot.HeritageId); _lastHeritageId = snapshot.HeritageId; _rowsBuilt = true; + if (preservedSkillId is { } skillId) + { + foreach (SkillRow candidate in _rows) + { + if (candidate.SkillId != skillId) + continue; + _selectedSkillId = skillId; + ApplySelectionHighlight(); + break; + } + } } foreach (SkillRow row in _rows) @@ -253,6 +348,23 @@ internal sealed class CharacterCreationSkillsPage : IDisposable credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); } + /// Group 2 closeout: true when any CURRENTLY BUILT row's live + /// bucket (recomputed from its skill's present level/MinLevel) no + /// longer matches the bucket it was last built into. + private bool AnyRowBucketChanged(IRuntimeCharacterCreationView view) + { + foreach (SkillRow row in _rows) + { + ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId); + uint minLevel = view.Options.TryGetSkillDetail(row.SkillId, out ChargenSkillDetail detail) + ? detail.MinLevel + : 1u; // Unknown detail (missing global SkillTable entry) defaults to useable — the least surprising fallback. + if (ComputeBucket(level, minLevel) != row.Bucket) + return true; + } + return false; + } + private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) { foreach (SkillRow row in _rows) @@ -264,8 +376,9 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _rows.Clear(); _list?.Flush(); - // The skill list is rebuilding under a (possibly new) heritage — - // any previously selected skill id may no longer exist as a row. + // The skill list is rebuilding — the caller (Refresh) decides + // whether to restore _selectedSkillId afterward (preserved across a + // bucket-move rebuild, cleared across a heritage change). _selectedSkillId = null; ClearInfoBox(); @@ -277,64 +390,111 @@ internal sealed class CharacterCreationSkillsPage : IDisposable return; } - // Templates[1] (0x100002FF) is the REAL skill row — see this - // class's own doc comment for the full byte trace. - UiTemplateListEntry template = _list.Templates[1]; + // Group 2 closeout: gather every costable skill's (id, name, bucket) + // first, group by bucket, sort each bucket alphabetically by name + // (InsertEntrySorted's own wcscmp compare), THEN build rows in + // DoSkillRecords' own header-then-rows-per-bucket order. + var byBucket = new Dictionary>(4) + { + [SkillBucket.Specialized] = [], + [SkillBucket.Trained] = [], + [SkillBucket.UseableUntrained] = [], + [SkillBucket.UnuseableUntrained] = [], + }; for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) { if (!IsCostable(heritage, view.Options, skillId)) continue; - if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId) - is not { } rowRoot) - { - continue; - } - - _list.AddPrebuiltRow(rowRoot); - - UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText; - if (nameText is not null) - SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId)); - // Captured AFTER SetLine (which never touches DefaultColor — - // it's read lazily inside the LinesProvider closure) so this is - // the row's own DAT-authored default color, for R2-4a's - // selection highlight to restore on deselect. - Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One; - UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText; - UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText; - UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText; - UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton; - UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton; - - uint capturedSkillId = skillId; - // R2-4a: retail re-selects the row after an arrow click too - // (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1) - // call following IncreaseSkillLevel/DecreaseSkillLevel). - if (upButton is not null) - upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); }; - if (downButton is not null) - downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); }; - - // R2-4a: the row-click equivalent of retail's listbox-level - // selection notification (idElement==0x100003f7 && - // idMessage==4 in ListenToElementMessage) — UiTemplateListBox - // has no generic selection mechanism of its own (see its class - // doc), so this page opts the row in directly. Templates[1] - // (0x100002FF) resolves through DatWidgetFactory's Type-3 - // (generic-container) fallback arm to UiDatElement, which - // already carries a page-opt-in OnClick/ClickThrough seam for - // exactly this — "generic decoration; behavioral widgets opt - // back in" (UiDatElement's own doc). - if (rowRoot is UiDatElement datRow) - { - datRow.ClickThrough = false; - datRow.OnClick = () => SelectRow(capturedSkillId); - } - - _rows.Add(new SkillRow( - rowRoot, skillId, nameText, levelText, upCostText, downCostText, - upButton, downButton, unselectedColor)); + ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); + uint minLevel = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail) + ? detail.MinLevel + : 1u; + string name = ItemAppraisalTextFormatter.SkillName((int)skillId); + byBucket[ComputeBucket(level, minLevel)].Add((skillId, name)); } + foreach (List<(uint SkillId, string Name)> bucketSkills in byBucket.Values) + bucketSkills.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name)); + + if (_list.Templates.Count < 1) + return; + UiTemplateListEntry headerTemplate = _list.Templates[0]; + // Templates[1] (0x100002FF) is the REAL skill row — see this + // class's own doc comment for the full byte trace. + UiTemplateListEntry rowTemplate = _list.Templates[1]; + + foreach ((SkillBucket bucket, string stringKey) in BucketOrder) + { + BuildHeaderRow(headerTemplate, stringKey); + foreach ((uint skillId, _) in byBucket[bucket]) + BuildSkillRow(rowTemplate, skillId, bucket); + } + } + + /// Builds one Templates[0] bucket-header row and writes + /// its caption () from the string + /// table — DoSkillRecords's own unconditional 4-header build, + /// regardless of whether the bucket ends up with any rows. + private void BuildHeaderRow(UiTemplateListEntry template, string stringKey) + { + if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } headerRoot) + return; + _list.AddPrebuiltRow(headerRoot); + if (UiElement.FindDescendant(headerRoot, HeaderCaptionElementId) is UiButton caption + && _bindings.ResolveText?.Invoke(stringKey) is { } text) + { + caption.Label = text; + } + } + + private void BuildSkillRow(UiTemplateListEntry template, uint skillId, SkillBucket bucket) + { + if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } rowRoot) + return; + + _list.AddPrebuiltRow(rowRoot); + + UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText; + if (nameText is not null) + SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId)); + // Captured AFTER SetLine (which never touches DefaultColor — + // it's read lazily inside the LinesProvider closure) so this is + // the row's own DAT-authored default color, for R2-4a's + // selection highlight to restore on deselect. + Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One; + UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText; + UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText; + UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText; + UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton; + UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton; + + uint capturedSkillId = skillId; + // R2-4a: retail re-selects the row after an arrow click too + // (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1) + // call following IncreaseSkillLevel/DecreaseSkillLevel). + if (upButton is not null) + upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); }; + if (downButton is not null) + downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); }; + + // R2-4a: the row-click equivalent of retail's listbox-level + // selection notification (idElement==0x100003f7 && + // idMessage==4 in ListenToElementMessage) — UiTemplateListBox + // has no generic selection mechanism of its own (see its class + // doc), so this page opts the row in directly. Templates[1] + // (0x100002FF) resolves through DatWidgetFactory's Type-3 + // (generic-container) fallback arm to UiDatElement, which + // already carries a page-opt-in OnClick/ClickThrough seam for + // exactly this — "generic decoration; behavioral widgets opt + // back in" (UiDatElement's own doc). + if (rowRoot is UiDatElement datRow) + { + datRow.ClickThrough = false; + datRow.OnClick = () => SelectRow(capturedSkillId); + } + + _rows.Add(new SkillRow( + rowRoot, skillId, bucket, nameText, levelText, upCostText, downCostText, + upButton, downButton, unselectedColor)); } private void RefreshRowValues( @@ -488,13 +648,24 @@ internal sealed class CharacterCreationSkillsPage : IDisposable if (_disposed) return; _selectedSkillId = skillId; + ApplySelectionHighlight(); + if (_bindings.View() is { } view) + RefreshInfoBox(view, view.Snapshot); + } + + /// Applies / + /// to every row's name text based on — + /// factored out of so can + /// re-apply it after a bucket-move rebuild restores a preserved + /// selection onto the NEW row objects (a rebuild discards the old ones, + /// so the highlight must be re-painted, not merely remembered). + private void ApplySelectionHighlight() + { foreach (SkillRow row in _rows) { if (row.NameText is { } nameText) - nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor; + nameText.DefaultColor = row.SkillId == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor; } - if (_bindings.View() is { } view) - RefreshInfoBox(view, view.Snapshot); } /// @@ -504,26 +675,45 @@ internal sealed class CharacterCreationSkillsPage : IDisposable /// when nothing is selected (retail's own arg2==0/lookup-miss /// arms, both UIElement_Text::ClearAllText). Title is the skill /// name plus its current score (" (%d)\n", e.g. "Loyalty (5)"). - /// Body is level-gated bonus text + /// Body is: DESCRIPTION, then level-gated bonus text /// ("Training Bonus +5"/"Specialization Bonus +10" — /// TWO spaces before the number, matching the compiled literal - /// verbatim) only. + /// verbatim), then 's "Formula : ..." line — + /// eax_2[7]/eax_2[8] off the row's cached + /// tagSkillRecord, byte-traced against tagSkillRecord's + /// own field order (acclient.h). Routed through + /// (escape-normalize + word-wrap, the + /// SAME composer the description pages use) since the description text + /// can run long enough to need wrapping in this box's width; composed + /// ONCE per call (not per-frame — this method itself only runs when + /// 's caller detects a revision change) and handed + /// to as a closed-over, already-built + /// list, matching the F11 no-per-frame-recompute discipline. /// /// - /// PARTIAL PORT — see the batch report: retail's body ALSO - /// prepends the skill's DESCRIPTION (SkillBase._description, - /// read via eax_2[7] off the row's own cached - /// tagSkillRecord) and appends - /// MakeSkillFormula @0x00480e10's computed "Formula : ..." text - /// (attribute names + weighted-formula arithmetic, sourced from - /// SkillBase._formula). Neither is reachable from this page's - /// current data surface: - /// carries per-skill COSTS only (never description/formula), and - /// has no resolver for - /// either (unlike , - /// which already exists for the score). Porting them needs a new - /// binding of that same shape, backed by the global SkillTable — out of - /// this file's edit contract for this batch. + /// Group 2 closeout (Campaign CC gate round 1): DESCRIPTION is a + /// byte-verified port (SkillBase._description, read directly off + /// the DAT) and the ONLY segment routed through + /// 's word-wrap — description text can + /// run arbitrarily long, unlike the bonus/formula lines below. The bonus + /// line ("Training Bonus +5"/"Specialization Bonus +10", + /// TWO spaces before the number, matching the compiled literal + /// verbatim) and 's result are each added as + /// their OWN single, UNWRAPPED — deliberately + /// bypassing DatRichText.Compose for these two, since its + /// word-splitting wrap () collapses + /// consecutive spaces when it rejoins tokens, which would silently + /// mangle the bonus line's own authored double-space formatting (caught + /// by SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine + /// during this closeout — routing it through the wrapper the first time + /// produced "Training Bonus +5 ", single space, trailing artifact from + /// the wrapper's own newline-as-empty-paragraph handling). 's + /// prefix/per-attribute-term/divisor/bonus-suffix shape is HIGH + /// CONFIDENCE (every piece is a directly-read compiled string literal or + /// a field the DatReaderWriter binding already exposes by name); the + /// CONNECTOR text between a two-attribute formula's two terms is a + /// documented approximation (register AP-231) — see that method's own + /// doc. /// /// private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) @@ -549,10 +739,109 @@ internal sealed class CharacterCreationSkillsPage : IDisposable ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10", _ => string.Empty, }; - SetLine(text, bonus); + + bool hasDetail = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail); + var lines = new List(); + if (hasDetail && !string.IsNullOrEmpty(detail.Description)) + { + lines.AddRange(DatRichText.Compose( + text, [new DatRichText.Segment(detail.Description, text.DefaultColor)])); + } + if (bonus.Length > 0) + lines.Add(new UiText.Line(bonus, text.DefaultColor)); + if (hasDetail) + lines.Add(new UiText.Line(ComposeFormula(detail.Formula), text.DefaultColor)); + + text.LinesProvider = () => lines; } } + /// + /// gmCGSkillsPage::MakeSkillFormula @0x00480e10 — retail's + /// formula-text composition. HIGH CONFIDENCE (directly read from + /// compiled string literals plus the field layout + /// shares with + /// the DatReaderWriter binding's own SkillFormula struct): the + /// "Formula : " prefix, the per-attribute "(%u x %s)"-vs- + /// bare-name choice (a term's own multiplier > 1 gets the + /// parenthesized multiply form, else just the attribute's name — the + /// exact eax_6 <= 1/ebx_3 <= 1 gate), the + /// " / %u" divisor suffix (gated on Divisor != 1, the + /// exact __saved_ebp_11 != 1 gate), and the " +%u" + /// additive-bonus suffix (gated on AdditiveBonus != 0, the exact + /// __saved_ebp_12 != 0 gate). + /// + /// + /// LOWER CONFIDENCE, disclosed rather than silently guessed + /// (register AP-231): the connector text between a two-attribute + /// formula's own two terms. This port renders " + " — the + /// well-known "(Attr1 + Attr2) / N" shape most published AC skill + /// formulas use — but the decompiled function's own two candidate + /// connector literals (data_7a01a4, appended between the terms; + /// data_797584, appended again immediately after BOTH terms are + /// present) could not be recovered byte-exact by this session's + /// static-only tooling (no live cdb attach, no running Ghidra MCP + /// instance): both sit behind reference-counted PStringBase + /// appends whose actual wide-character content Binary Ninja's HLIL does + /// not surface as a literal, and the surrounding control flow (a + /// goto-based re-convergence between the single-attribute and + /// dual-attribute code paths) left data_797584's exact role + /// ambiguous enough that this port does NOT invent a second connector + /// for it — a two-attribute skill's formula therefore renders as + /// "Formula : (2 x Strength) + Endurance / 4 +2"-shaped text + /// that is very likely retail-correct in STRUCTURE but not yet + /// byte-verified against a live capture. Single-attribute formulas (the + /// majority of skills) are unaffected by this gap. + /// + /// + private static string ComposeFormula(ChargenSkillFormula formula) + { + bool attribute1Active = formula.Attribute1Multiplier >= 1 && formula.Attribute1 != 0; + bool attribute2Active = formula.Attribute2Multiplier >= 1 && formula.Attribute2 != 0; + + var builder = new StringBuilder("Formula : "); + if (attribute1Active) + { + AppendAttributeTerm(builder, formula.Attribute1Multiplier, formula.Attribute1); + if (attribute2Active) + builder.Append(" + "); + } + if (attribute2Active) + AppendAttributeTerm(builder, formula.Attribute2Multiplier, formula.Attribute2); + + if (formula.Divisor != 1) + builder.Append(CultureInfo.InvariantCulture, $" / {formula.Divisor}"); + if (formula.AdditiveBonus != 0) + builder.Append(CultureInfo.InvariantCulture, $" +{formula.AdditiveBonus}"); + return builder.ToString(); + } + + private static void AppendAttributeTerm(StringBuilder builder, int multiplier, uint attributeId) + { + string name = AttributeName((ChargenAttributeId)attributeId); + if (multiplier > 1) + builder.Append(CultureInfo.InvariantCulture, $"({multiplier} x {name})"); + else + builder.Append(name); + } + + /// Ports CharGenState::GetAttributeName @ 0x005C3A20 + /// verbatim — retail hardcodes these six literals directly (not a + /// DAT/localization lookup). Duplicated locally from + /// CharacterCreationProfessionPage's own private copy rather than + /// extracted to a shared helper — six lines, two call sites, not worth + /// a new file for this closeout's scope. + private static string AttributeName(ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => "Strength", + ChargenAttributeId.Endurance => "Endurance", + ChargenAttributeId.Quickness => "Quickness", + ChargenAttributeId.Coordination => "Coordination", + ChargenAttributeId.Focus => "Focus", + ChargenAttributeId.Self => "Self", + _ => string.Empty, + }; + private void ClearInfoBox() { if (_infoTitle is { } title) SetLine(title, string.Empty); diff --git a/src/AcDream.Content/CharGen/ChargenTableReader.cs b/src/AcDream.Content/CharGen/ChargenTableReader.cs index 127a215f..f902ab79 100644 --- a/src/AcDream.Content/CharGen/ChargenTableReader.cs +++ b/src/AcDream.Content/CharGen/ChargenTableReader.cs @@ -81,22 +81,39 @@ public static class ChargenTableReader heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value); var globalSkillCosts = new Dictionary(skillTable?.Skills.Count ?? 0); + // Group 2 (Campaign CC gate round 1 closeout): SkillBase.MinLevel/ + // Description/Formula — GLOBAL only, no per-heritage counterpart + // (see ChargenSkillDetail's own doc). + var globalSkillDetails = new Dictionary(skillTable?.Skills.Count ?? 0); if (skillTable is not null) { foreach (KeyValuePair pair in skillTable.Skills) { uint skillId = (uint)pair.Key; + SkillBase skill = pair.Value; globalSkillCosts[skillId] = new ChargenSkillCost( skillId, - pair.Value.TrainedCost, - pair.Value.SpecializedCost); + skill.TrainedCost, + skill.SpecializedCost); + globalSkillDetails[skillId] = new ChargenSkillDetail( + skillId, + skill.MinLevel, + skill.Description.Value, + new ChargenSkillFormula( + skill.Formula.AdditiveBonus, + skill.Formula.Attribute1Multiplier, + skill.Formula.Attribute2Multiplier, + skill.Formula.Divisor, + (uint)skill.Formula.Attribute1, + (uint)skill.Formula.Attribute2)); } } return new ChargenOptions( Array.AsReadOnly(starterAreas), heritagesById.ToFrozenDictionary(), - globalSkillCosts.ToFrozenDictionary()); + globalSkillCosts.ToFrozenDictionary(), + globalSkillDetails.ToFrozenDictionary()); } private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area) diff --git a/src/AcDream.Core/CharGen/ChargenOptions.cs b/src/AcDream.Core/CharGen/ChargenOptions.cs index 218479f1..cd631e48 100644 --- a/src/AcDream.Core/CharGen/ChargenOptions.cs +++ b/src/AcDream.Core/CharGen/ChargenOptions.cs @@ -26,10 +26,21 @@ namespace AcDream.Core.CharGen; /// per-gender appearance option lists, skill costs — hangs off this one /// root. /// +/// +/// Campaign CC gate round 1 closeout (Group 2): the global SkillTable's +/// MinLevel/Description/Formula per skill (see 's +/// own doc for why these are GLOBAL-only, unlike +/// which also has a per-heritage counterpart). Defaults to null (not an +/// empty dictionary) so every pre-existing caller that builds a +/// without this parameter — five test fixtures +/// plus ChargenOptions.Empty below — compiles and behaves exactly as +/// before; treats null the same as "empty." +/// public sealed record ChargenOptions( IReadOnlyList StarterAreas, IReadOnlyDictionary HeritagesById, - IReadOnlyDictionary GlobalSkillCostsBySkillId) + IReadOnlyDictionary GlobalSkillCostsBySkillId, + IReadOnlyDictionary? GlobalSkillDetailsBySkillId = null) { public static ChargenOptions Empty { get; } = new( Array.Empty(), @@ -49,4 +60,13 @@ public sealed record ChargenOptions( area = default; return false; } + + /// See 's own doc. + public bool TryGetSkillDetail(uint skillId, [MaybeNullWhen(false)] out ChargenSkillDetail detail) + { + if (GlobalSkillDetailsBySkillId is { } details && details.TryGetValue(skillId, out detail)) + return true; + detail = default; + return false; + } } diff --git a/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs index cba65741..cb6a4b3a 100644 --- a/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs +++ b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs @@ -26,6 +26,53 @@ public enum ChargenSkillAdvancementClass : uint /// public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost); +/// +/// gmCGSkillsPage::MakeSkillFormula @0x00480e10's six raw inputs — +/// retail's SkillFormula struct (acclient.h) verbatim field +/// order/shape: _w=, +/// _x=, +/// _y=, _z=, +/// _attr1=, _attr2=. +/// / are the raw 1-6 retail +/// attribute id (matching AcDream.Runtime.Session.ChargenAttributeId's +/// own numbering exactly — Strength=1..Self=6) rather than that enum type +/// itself, since Core does not (and must not) reference Runtime; the App +/// layer, which already references both, does the enum cast at the one +/// call site that needs an attribute NAME. +/// +public readonly record struct ChargenSkillFormula( + int AdditiveBonus, + int Attribute1Multiplier, + int Attribute2Multiplier, + int Divisor, + uint Attribute1, + uint Attribute2); + +/// +/// One skill's GLOBAL (heritage-independent) presentation data — retail's +/// SkillBase._min_level/_description/_formula fields, +/// sourced ONLY from the portal.dat SkillTable. Distinct from +/// (which exists BOTH per-heritage +/// (SkillCG) AND globally) because these three fields have NO +/// per-heritage override in retail at all — SkillCG (the per- +/// heritage cost record HeritageGroupCG.Skills projects) carries +/// only Id/NormalCost/PrimaryCost, verified against the +/// DatReaderWriter binding. +/// +/// +/// Retail's _min_level is typed SKILL_ADVANCEMENT_CLASS, not a +/// character level — gmCGSkillsPage::UpdateSkillEntry @0x00480bf0's +/// own bucket test (arg2->iMinlevel <= 1) reads it as "the +/// lowest at which this skill is +/// USEABLE" — <= 1 (Inactive/Untrained) means useable while +/// untrained, == 2 (Trained) means training is required first. +/// +public readonly record struct ChargenSkillDetail( + uint SkillId, + uint MinLevel, + string Description, + ChargenSkillFormula Formula); + /// /// Retail's fixed-size per-character skill-advancement array /// (CharGenState.skillLevels). ACE's CharacterCreateInfo.Unpack diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 089525d8..f3607692 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -335,12 +335,20 @@ public sealed class CharacterCreationUiControllerTests environment.TabButton(CharacterCreationUiController.SkillsTabElementId) .OnClick!(); - IReadOnlyList rows = environment.SkillsList().ViewportForTest!.Children; + IReadOnlyList children = environment.SkillsList().ViewportForTest!.Children; + // Group 2 closeout: the listbox now ALSO carries the four bucket- + // header rows (DoSkillRecords' own unconditional 4-header build, + // present regardless of whether a bucket is empty) — filter to + // genuine skill rows (carry a 0x10000301 name descendant; headers + // carry only 0x100002f6) before counting. + List skillRows = [.. children.Where( + candidate => UiElement.FindDescendant(candidate, 0x10000301u) is not null)]; + Assert.Equal(4, children.Count - skillRows.Count); // four bucket headers, always built. // Aluvian's fixture costs SkillTrainOnly(1)/SkillSpecializable(2)/ // SkillFreeTrained(3, added for the F2 arrow-lock coverage below). - Assert.Equal(3, rows.Count); + Assert.Equal(3, skillRows.Count); - UiElement row = Assert.Single(rows, candidate => + UiElement row = Assert.Single(skillRows, candidate => UiElement.FindDescendant(candidate, 0x10000301u) is UiText name && JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); @@ -363,10 +371,21 @@ public sealed class CharacterCreationUiControllerTests // production's TrySetSkillLevel, RuntimeCharacterCreationState.cs // ~1045), so force one the same way the file's other post-click // refresh assertions do. + // + // Group 2 closeout: advancing also moves the row from the + // UseableUntrained bucket to the Trained bucket, which rebuilds + // every row — row/upCost/downCost captured above are now stale, so + // re-fetch by name (the SAME lookup every other test in this file + // uses post-rebuild) instead of reusing them. environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 }; environment.Controller.Tick(); + row = Assert.Single(environment.SkillsList().ViewportForTest!.Children, candidate => + UiElement.FindDescendant(candidate, 0x10000301u) is UiText name + && JoinedText(name) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); + upCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000303u)); + downCost = Assert.IsType(UiElement.FindDescendant(row, 0x10000306u)); Assert.Equal("4", JoinedText(upCost)); Assert.Equal("2", JoinedText(downCost)); } @@ -377,10 +396,11 @@ public sealed class CharacterCreationUiControllerTests /// NAME text swaps to Vector4.One (the best-derived "brighter /// white") and the info title /// (ShowSkillsText @0x00481250's " (%d)" score suffix) - /// populates. Untrained/Inactive carries no bonus line, so the info - /// TEXT pane stays blank (the still-missing description/formula halves - /// — see 's own - /// doc). + /// populates. Group 2 closeout: Untrained/Inactive carries no BONUS + /// line, but the info TEXT pane is no longer blank — the DESCRIPTION + /// and MakeSkillFormula lines both render regardless of level + /// (see 's own + /// doc for the composition order). [Fact] public void SkillsPage_RowClick_SelectsRow_HighlightsNameAndPopulatesInfoBoxTitle() { @@ -404,7 +424,13 @@ public sealed class CharacterCreationUiControllerTests string expectedTitle = $"{ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)} ({SkillTrainOnly * 10u})"; Assert.Equal(expectedTitle, JoinedText(environment.SkillInfoTitle())); - Assert.Equal(string.Empty, JoinedText(environment.SkillInfoText())); + // SkillTrainOnly's fixture detail: description "A test skill + // description.", formula (2 x Strength) / 4 +2, no bonus line + // (Untrained). JoinedText's own single-space join collapses the + // description's own word-wrapped line break. + Assert.Equal( + "A test skill description. Formula : (2 x Strength) / 4 +2", + JoinedText(environment.SkillInfoText())); } /// R2-4a: retail re-selects the row after an arrow click too @@ -413,7 +439,11 @@ public sealed class CharacterCreationUiControllerTests /// IncreaseSkillLevel/DecreaseSkillLevel) — the info TEXT pane tracks /// the level-gated bonus line as the skill advances (the TWO-space /// literal "Training Bonus +5"/"Specialization Bonus +10", - /// matching the compiled string verbatim). + /// matching the compiled string verbatim, unwrapped end to end — see + /// 's own doc for + /// why the bonus line is never routed through the word-wrap the + /// description segment gets). Group 2 closeout: the description and + /// formula lines now bracket the bonus line on every assertion below. [Fact] public void SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine() { @@ -426,11 +456,20 @@ public sealed class CharacterCreationUiControllerTests up.OnClick!(); // Untrained/Inactive -> Trained. BumpRevisionAndTick(environment); - Assert.Equal("Training Bonus +5", JoinedText(environment.SkillInfoText())); + Assert.Equal( + "A test skill description. Training Bonus +5 Formula : (2 x Strength) / 4 +2", + JoinedText(environment.SkillInfoText())); + // Group 2 closeout: the Untrained -> Trained move above re-buckets + // the row (rebuilding every row and nulling the OLD button's + // OnClick as teardown) — re-fetch by name before the second click + // instead of reusing the pre-rebuild `up` reference. + (up, _) = environment.SkillRowArrows(SkillTrainOnly); up.OnClick!(); // Trained -> Specialized. BumpRevisionAndTick(environment); - Assert.Equal("Specialization Bonus +10", JoinedText(environment.SkillInfoText())); + Assert.Equal( + "A test skill description. Specialization Bonus +10 Formula : (2 x Strength) / 4 +2", + JoinedText(environment.SkillInfoText())); } /// R2-4a: selecting a SECOND row restores the FIRST row's own @@ -503,17 +542,26 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(0x1000001Bu, up.ActiveRetailStateId); Assert.Equal(0x1000001Au, down.ActiveRetailStateId); + // Group 2 closeout: advancing a skill can move its row into a NEW + // bucket (UpdateSkillEntry's own re-bucket), which rebuilds every + // row — up/down/freeUp/freeDown are re-fetched by name after each + // level-changing click instead of reusing the pre-click widget + // references, which would otherwise be silently stale (never + // refreshed again once their row is discarded). up.OnClick!(); // -> Trained. trainedCost(2) != 0 -> Down enabled. BumpRevisionAndTick(environment); + (up, down) = environment.SkillRowArrows(SkillTrainOnly); Assert.Equal(0x1000001Bu, down.ActiveRetailStateId); (UiButton freeUp, UiButton freeDown) = environment.SkillRowArrows(SkillFreeTrained); freeUp.OnClick!(); // -> Trained. trainedCost(0) == 0 -> Down locked. BumpRevisionAndTick(environment); + (freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained); Assert.Equal(0x1000001Au, freeDown.ActiveRetailStateId); freeUp.OnClick!(); // -> Specialized. specCost(6) != 0 -> Down unlocks; BumpRevisionAndTick(environment); // Up is now ALWAYS ghosted. + (freeUp, freeDown) = environment.SkillRowArrows(SkillFreeTrained); Assert.Equal(0x1000001Au, freeUp.ActiveRetailStateId); Assert.Equal(0x1000001Bu, freeDown.ActiveRetailStateId); } @@ -534,6 +582,96 @@ public sealed class CharacterCreationUiControllerTests Assert.Same(environment.SkillsList().Scroll, scrollbar.Model); } + // ── Campaign CC gate round 1 closeout: Group 2 (four-bucket model) ── + + /// DoSkillRecords's own unconditional 4-header build — + /// every bucket header is present, in Specialized/Trained/ + /// UseableUntrained/UnuseableUntrained order, even though this + /// fixture's three skills leave the Specialized bucket empty. + [Fact] + public void SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_Specialized"] = "Specialized"; + environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained"; + environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained"; + environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained"; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + List headerCaptions = [.. environment.SkillsList().ViewportForTest!.Children + .Where(candidate => UiElement.FindDescendant(candidate, 0x10000301u) is null) + .Select(candidate => Assert.IsType( + UiElement.FindDescendant(candidate, 0x100002F6u)).Label!)]; + + Assert.Equal( + ["Specialized", "Trained", "Useable Untrained", "Unuseable Untrained"], + headerCaptions); + } + + /// UpdateSkillEntry's own iMinlevel <= 1 split: + /// while Untrained, SkillTrainOnly (fixture MinLevel 1) is useable and + /// SkillSpecializable (fixture MinLevel 2) is not — they land in + /// DIFFERENT buckets even though both start Untrained (the default, + /// unset, FakeView.GetSkillLevel state). + [Fact] + public void SkillsPage_UntrainedSkill_BucketsByMinLevel() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained"; + environment.Runtime.ResolvedStrings["ID_CharGen_UnuseableUntrained"] = "Unuseable Untrained"; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + List children = [.. environment.SkillsList().ViewportForTest!.Children]; + int useableHeaderIndex = children.FindIndex(c => + UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained"); + int unuseableHeaderIndex = children.FindIndex(c => + UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Unuseable Untrained"); + int trainOnlyRowIndex = children.FindIndex(c => + UiElement.FindDescendant(c, 0x10000301u) is UiText n + && JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); + int specializableRowIndex = children.FindIndex(c => + UiElement.FindDescendant(c, 0x10000301u) is UiText n + && JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable)); + + Assert.InRange(trainOnlyRowIndex, useableHeaderIndex + 1, unuseableHeaderIndex - 1); + Assert.True(specializableRowIndex > unuseableHeaderIndex); + } + + /// Advancing a skill re-buckets its row — the row's position + /// moves from the UseableUntrained section to the Trained section, + /// matching UpdateSkillEntry's own remove-and-reinsert (ported + /// here as a detected full rebuild, not an incremental single-row + /// move — see 's own class doc + /// for why that substitution is faithful to the OBSERVABLE result). + [Fact] + public void SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_Trained"] = "Trained"; + environment.Runtime.ResolvedStrings["ID_CharGen_UseableUntrained"] = "Useable Untrained"; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + environment.SkillRowArrows(SkillTrainOnly).Up.OnClick!(); // Untrained -> Trained. + BumpRevisionAndTick(environment); + + List children = [.. environment.SkillsList().ViewportForTest!.Children]; + int trainedHeaderIndex = children.FindIndex(c => + UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Trained"); + int useableHeaderIndex = children.FindIndex(c => + UiElement.FindDescendant(c, 0x100002F6u) is UiButton b && b.Label == "Useable Untrained"); + int rowIndex = children.FindIndex(c => + UiElement.FindDescendant(c, 0x10000301u) is UiText n + && JoinedText(n) == ItemAppraisalTextFormatter.SkillName((int)SkillTrainOnly)); + + Assert.InRange(rowIndex, trainedHeaderIndex + 1, useableHeaderIndex - 1); + } + [Fact] public void TownButton_SelectsTheLiteralStartAreaIndex() { @@ -2374,6 +2512,31 @@ public sealed class CharacterCreationUiControllerTests [SkillFreeTrained] = new(SkillFreeTrained, NormalCost: 0, PrimaryCost: 6), }; + // Group 2 closeout: global SkillTable detail (MinLevel/ + // Description/Formula) — SkillTrainOnly is useable while + // Untrained (MinLevel 1) and carries a real description + + // single-attribute formula for the info-box completion tests; + // SkillSpecializable requires Trained first (MinLevel 2), the + // useable-vs-unuseable-untrained bucket split's own test case. + var skillDetails = new Dictionary + { + [SkillTrainOnly] = new ChargenSkillDetail( + SkillTrainOnly, + MinLevel: 1u, + Description: "A test skill description.", + Formula: new ChargenSkillFormula( + AdditiveBonus: 2, + Attribute1Multiplier: 2, + Attribute2Multiplier: 0, + Divisor: 4, + Attribute1: (uint)ChargenAttributeId.Strength, + Attribute2: 0u)), + [SkillSpecializable] = new ChargenSkillDetail( + SkillSpecializable, MinLevel: 2u, Description: string.Empty, Formula: default), + [SkillFreeTrained] = new ChargenSkillDetail( + SkillFreeTrained, MinLevel: 1u, Description: string.Empty, Formula: default), + }; + var aluvian = new ChargenHeritageOptions( AluvianId, "Aluvian", @@ -2426,7 +2589,8 @@ public sealed class CharacterCreationUiControllerTests [AluvianId] = aluvian, [OlthoiId] = olthoi, }, - new Dictionary()); + new Dictionary(), + skillDetails); } } @@ -2806,16 +2970,22 @@ public sealed class CharacterCreationUiControllerTests return LayoutImporter.Build(row, _ => (0u, 0, 0), null).Root; } - return LayoutImporter.Build( - new ElementInfo - { - Id = templateElementId, - Type = 1u, - Width = 280f, - Height = 16f, - }, - _ => (0u, 0, 0), - null).Root; + // Group 2 closeout: Templates[0] (0x100002F4) — retail's own + // bucket-header row, now consumed by the four-bucket rebuild. A + // plain container root (Type 3, same shape as Templates[1]'s own + // row — a Type-1 UiButton root would CONSUME its own children and + // hide the caption), carrying the caption child (0x100002f6, a + // UiButton, read via .Label — CharacterCreationSkillsPage's own + // HeaderCaptionElementId doc). + var header = new ElementInfo + { + Id = templateElementId, + Type = 3u, + Width = 280f, + Height = 16f, + }; + header.Children.Add(ButtonInfo(0x100002F6u)); + return LayoutImporter.Build(header, _ => (0u, 0, 0), null).Root; } // ── Summary page fixture (CC5) ─────────────────────────────────────── diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs index 0753ef07..60a015f5 100644 --- a/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs +++ b/tests/AcDream.Content.Tests/CharGen/ChargenTableReaderInstalledDatTests.cs @@ -277,6 +277,54 @@ public sealed class ChargenTableReaderInstalledDatTests } } + /// + /// Campaign CC gate round 1 closeout (Group 2): pins + /// 's real + /// shape against the installed DAT — same 38-entry population as + /// (both read the + /// SAME SkillTable loop), MinLevel distributed 23 at <= 1 + /// (useable while Untrained) / 15 at exactly 2 (Trained + /// required), per the Batch F investigation's own recorded finding, and + /// never above 2 — UpdateSkillEntry's own bucket test only ever + /// distinguishes <= 1 from > 1, so a MinLevel of 3 + /// would be observationally identical to 2 but is worth flagging if a + /// future DAT drop ever introduces one. + /// + [Fact] + public void InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage() + { + string? datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) + { + Console.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ChargenOptions options = ChargenTableReader.Load(adapter); + + Assert.Equal(options.GlobalSkillCostsBySkillId.Count, options.GlobalSkillDetailsBySkillId!.Count); + Assert.Equal(38, options.GlobalSkillDetailsBySkillId.Count); + + int useableUntrained = 0; + int trainedRequired = 0; + bool anyDescriptionNonEmpty = false; + foreach (ChargenSkillDetail detail in options.GlobalSkillDetailsBySkillId.Values) + { + Assert.True(detail.MinLevel <= 2u, $"skill {detail.SkillId}: MinLevel {detail.MinLevel} exceeds Trained(2)."); + if (detail.MinLevel <= 1u) + useableUntrained++; + else + trainedRequired++; + anyDescriptionNonEmpty |= !string.IsNullOrEmpty(detail.Description); + } + + Assert.Equal(23, useableUntrained); + Assert.Equal(15, trainedRequired); + Assert.True(anyDescriptionNonEmpty, "Expected at least one skill to carry a non-empty description."); + } + /// /// F5's strengthened installed-DAT gate. /// only proves an OR across eight lists for at least one gender per From bd359d5181dd66de6d526b79301c4543caf22ec1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 15:34:07 +0200 Subject: [PATCH 127/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20closeout=20=E2=80=94=20Group=203:=20round=20review?= =?UTF-8?q?=20fixes=20(F4-F11,=20F14,=20F16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining code-bearing findings from the round review, F4-F16 minus the doc-only items (batched separately): - F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly the 4 Town buttons, confined to chargen; conflicting custom-selection- pair + standard Normal/Highlight media — zero found, no gate tightening needed; per-state label-color map — 209 matches beyond chargen, confirming AP-222's mechanism has always been broadly active since it shipped generically in DatWidgetFactory). - F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now honors a child's own AuthoredInvisible flag (a narrow honor scoped to exactly that carve-out, not the general #408 client-wide one) — the chat transcript's new-text indicator (0x1000048C) was building as a visible phantom element retail never shows; verified both directions against the gold-frame pieces, which do not author Invisible. - F7: BoundedProcessOutputCapture.AppendLine combines the line text and its trailing newline into one buffer and one file open/write/close instead of two. - F9: corrected a stale comment in RuntimeSettingsTargets — #407 split DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the fullscreen validator's own narrower list is now DELIBERATELY different from the Config dropdown's fuller offering, not the "must match" bug the comment described. - F10: documented (not changed) why the LabelBox path's default 3px inset and the face-relative +4px gap in DatWidgetFactory.BuildButton are deliberately different numbers — neither carries a retail citation, and moving either to match the other would be an unfounded guess on a button that currently works correctly. - F11: Heritage/Profession/Summary/Town description pages now compose DatRichText.Compose's result ONCE inside their already revision-gated Refresh, caching the built line list instead of re-wrapping on every draw call. - F14: documented (not changed) why PrivateEntityViewportRenderer's _animatedIds set carrying a reserved-but-never-drawn backdrop id is harmless — BuildDrawEntities already excludes a null/empty backdrop from the actual draw list, so the id is never looked up. - F16: the Summary preview now uses its own render-id pair (SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/ 0xDA11D036) instead of sharing the Appearance page's (0xDA11D032/0xDA11D034) — confirmed by tracing FixedEntityTextureOwnerLease through TextureCache to CompositeTextureArrayCache's shared owner tracker that both pages' previews share ONE process-wide TextureCache, so sharing render ids was a real cross-page texture-release collision (either page's own re-dress or disposal could release the OTHER page's still-active textures), not a theoretical one. F3's own register bookkeeping (AP-229 addendum) and F12's register/AD header-count corrections land in the docs-only commit alongside F15. Co-Authored-By: Claude Fable 5 --- .../LivePresentationComposition.cs | 31 ++- .../Rendering/ChargenPreviewController.cs | 22 +- .../Rendering/ChargenPreviewEntityBuilder.cs | 54 ++++- .../Rendering/ChargenPreviewRenderer.cs | 16 +- .../PrivateEntityViewportRenderer.cs | 15 ++ .../Settings/RuntimeSettingsTargets.cs | 23 +- .../Layout/CharacterCreationHeritagePage.cs | 8 +- .../Layout/CharacterCreationProfessionPage.cs | 7 +- .../UI/Layout/CharacterCreationSummaryPage.cs | 11 +- .../UI/Layout/CharacterCreationTownPage.cs | 7 +- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 21 +- src/AcDream.App/UI/Layout/LayoutImporter.cs | 16 +- .../Launching/BoundedProcessOutputCapture.cs | 21 +- .../ChargenPreviewEntityBuilderTests.cs | 81 +++++++ ...youtImporterMediaBearingChildSweepTests.cs | 96 +++++++- .../UI/Layout/UiButtonCorpusSweepTests.cs | 214 ++++++++++++++++++ .../BoundedProcessOutputCaptureTests.cs | 30 +++ 17 files changed, 636 insertions(+), 37 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index a4b66ff8..6029f3f5 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1071,6 +1071,15 @@ internal sealed class LivePresentationCompositionPhase chargenCatalog, d.DatLock); interaction.RetainedUi.Runtime.ChargenPreviewControl = chargenPreviewController; + // Campaign CC gate round 1 closeout (Group 1, R2-5): the two + // Batch G STOPPED items land here — chargenCatalog already + // implements all three color-wheel seams (TryGetPalSet/ + // TryGetClothingTable/TryGetColor), same instance as the + // preview control just above, same one-shot composition-time + // assignment. + interaction.RetainedUi.Runtime.ChargenPalSetSource = chargenCatalog; + interaction.RetainedUi.Runtime.ChargenClothingTableSource = chargenCatalog; + interaction.RetainedUi.Runtime.ChargenPaletteColorSource = chargenCatalog; bindings.AdoptRelease( "chargen preview control", () => @@ -1142,7 +1151,17 @@ internal sealed class LivePresentationCompositionPhase foundation.SceneLighting!, foundation.TextureCache, foundation.MeshAdapter!, - camera: summaryCamera), + camera: summaryCamera, + // F16 (Campaign CC gate round 1 closeout): the Summary + // page's OWN render-id pair — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's + // own doc for why sharing the Appearance page's pair + // (the pre-existing default) is a real cross-page + // texture-release collision, not merely untidy, since + // both pages share the SAME foundation.TextureCache + // passed one line above. + renderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewRenderId, + backdropRenderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId), static value => value.Dispose()); IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer; summaryViewport.Renderer = summaryPreviewLease.Resource; @@ -1170,7 +1189,15 @@ internal sealed class LivePresentationCompositionPhase // OUT full-body framing (gmCGSummaryPage::InitializePage @ // 0x0047bbf0), not the Appearance page's zoomed-in default — // see ChargenPreviewController's own ctor doc comment. - useZoomedOutEye: true); + useZoomedOutEye: true, + // F16 (Campaign CC gate round 1 closeout): MUST match the + // renderId/backdropRenderId pair given to summaryPreviewLease's + // own ChargenPreviewRenderer above — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own + // doc for why sharing the Appearance page's pair here would + // be a real cross-page TextureCache collision. + renderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewRenderId, + backdropRenderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId); interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController; bindings.AdoptRelease( "summary preview control", diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs index 42d4cfca..0b56adb8 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -180,6 +180,8 @@ internal sealed class ChargenPreviewController : private readonly IChargenClothingTableSource _clothingTables; private readonly object _datLock; private readonly bool _useZoomedOutEye; + private readonly uint _renderId; + private readonly uint _backdropRenderId; private readonly Stopwatch _clock = Stopwatch.StartNew(); private ChargenPreviewAnimator? _animator; @@ -228,7 +230,19 @@ internal sealed class ChargenPreviewController : IChargenPalSetSource palSets, IChargenClothingTableSource clothingTables, object datLock, - bool useZoomedOutEye = false) + bool useZoomedOutEye = false, + // F16 (Campaign CC gate round 1 closeout): the render-id pair this + // controller stamps on the entities it builds — MUST match the + // pair the sibling ChargenPreviewRenderer was constructed with (see + // that class's own renderId/backdropRenderId parameters), since + // both feed the SAME shared TextureCache owner-tracking key. + // Defaults to the Appearance page's pair; the composition root + // passes the Summary pair explicitly for its own instance — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for + // why sharing the default here would be a real collision, not + // merely untidy. + uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId, + uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId) { _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _camera = camera ?? throw new ArgumentNullException(nameof(camera)); @@ -239,6 +253,8 @@ internal sealed class ChargenPreviewController : _clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _useZoomedOutEye = useZoomedOutEye; + _renderId = renderId; + _backdropRenderId = backdropRenderId; _rotation = new ChargenPreviewRotationController(); // Seed the eye NOW, matching whatever the first Rebuild's own // heritageOrGenderChanged branch below would otherwise defer until @@ -298,7 +314,7 @@ internal sealed class ChargenPreviewController : Quaternion heading = MoveToMath.SetHeading( Quaternion.Identity, _rotation.HeadingDegrees); ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( - _dats, _animations, result, heritageId, heading, _datLock); + _dats, _animations, result, heritageId, heading, _datLock, _renderId); if (build is null) return false; @@ -335,7 +351,7 @@ internal sealed class ChargenPreviewController : WorldEntity? backdrop = options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) ? ChargenPreviewEntityBuilder.TryBuildBackdrop( - _dats, heritage!.EnvironmentSetupId, _datLock) + _dats, heritage!.EnvironmentSetupId, _datLock, _backdropRenderId) : null; _renderer.SetBackdrop(backdrop); } diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs index fdf74162..b6cf6f73 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -117,6 +117,37 @@ internal static class ChargenPreviewEntityBuilder /// member (gmCG3DView::m_pbgObject). public const uint PreviewBackdropRenderId = 0xDA11_D034u; + /// + /// F16 (Campaign CC gate round 1 closeout, 2026-08-16): the Summary + /// page's OWN preview render-local id — DISTINCT from + /// . Both the Appearance and Summary pages + /// construct their own ChargenPreviewRenderer, but they share + /// ONE process-wide TextureCache (Wb.IEntityTextureLifetime) + /// via LivePresentationComposition's foundation.TextureCache + /// — confirmed by tracing FixedEntityTextureOwnerLease.Replace → + /// TextureCache.ReleaseOwnerCompositeTextureArrayCache.ReleaseOwner + /// → its own _owners tracker, keyed ONLY by the raw + /// ownerLocalId uint with no per-renderer namespace. Both pages + /// are mounted as PERMANENT siblings (register AP-229) and can be + /// simultaneously live, so two PrivateEntityViewportRenderer + /// instances sharing would share this + /// SAME owner bucket: either page re-dressing its own entity (a + /// FixedEntityTextureOwnerLease.Replace call) or being disposed + /// would call ReleaseOwner(PreviewRenderId) and release textures + /// the OTHER page's preview is still actively drawing with — a real + /// cross-page texture-corruption path, not a theoretical one. Reserved + /// in the SAME 0xDA11D0xx synthetic family, next free slot after the + /// Appearance page's own pair. + /// + public const uint SummaryPreviewRenderId = 0xDA11_D035u; + + /// F16: the Summary page's own backdrop render-local id, + /// paired with exactly as + /// pairs with + /// — see that constant's own doc for why a + /// distinct id is required, not merely tidy. + public const uint SummaryPreviewBackdropRenderId = 0xDA11_D036u; + /// /// Retail's held-pose (REST) animation DID enum key, resolved through /// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid @@ -188,10 +219,11 @@ internal static class ChargenPreviewEntityBuilder ChargenAppearanceResult appearance, uint heritageId, Quaternion heading, - object datLock) + object datLock, + uint renderId = PreviewRenderId) { ChargenPreviewAnimatedBuild? build = TryBuildAnimated( - dats, animations, appearance, heritageId, heading, datLock); + dats, animations, appearance, heritageId, heading, datLock, renderId); if (build is null) return null; @@ -214,7 +246,13 @@ internal static class ChargenPreviewEntityBuilder ChargenAppearanceResult appearance, uint heritageId, Quaternion heading, - object datLock) + object datLock, + // F16 (Campaign CC gate round 1 closeout): the Appearance and + // Summary pages both call this method through their own + // ChargenPreviewController, but must NOT stamp the same Id on + // both entities — see SummaryPreviewRenderId's own doc for the + // full TextureCache collision trace this id also feeds. + uint renderId = PreviewRenderId) { ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(animations); @@ -292,7 +330,7 @@ internal static class ChargenPreviewEntityBuilder var entity = new WorldEntity { - Id = PreviewRenderId, + Id = renderId, ServerGuid = PreviewServerGuid, SourceGfxObjOrSetupId = setupId, Position = Vector3.Zero, @@ -363,7 +401,11 @@ internal static class ChargenPreviewEntityBuilder public static WorldEntity? TryBuildBackdrop( IDatReaderWriter dats, uint environmentSetupId, - object datLock) + object datLock, + // F16 (Campaign CC gate round 1 closeout): see TryBuildAnimated's + // own renderId parameter doc — same Appearance-vs-Summary + // distinction, applied to the backdrop entity. + uint renderId = PreviewBackdropRenderId) { ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(datLock); @@ -389,7 +431,7 @@ internal static class ChargenPreviewEntityBuilder return new WorldEntity { - Id = PreviewBackdropRenderId, + Id = renderId, ServerGuid = PreviewBackdropServerGuid, SourceGfxObjOrSetupId = environmentSetupId, Position = Vector3.Zero, diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs index d643e2b1..898710d8 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -80,7 +80,17 @@ internal sealed class ChargenPreviewRenderer : IEntityTextureLifetime textureLifetime, IWbMeshAdapter meshAdapter, uint heritageId = 0u, - ChargenPreviewCamera? camera = null) + ChargenPreviewCamera? camera = null, + // F16 (Campaign CC gate round 1 closeout): the Appearance and + // Summary pages each construct their OWN ChargenPreviewRenderer but + // share ONE process-wide TextureCache — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for + // the full collision trace. Defaulting to the Appearance page's + // pair keeps every pre-existing call site byte-identical; the + // composition root passes the Summary pair explicitly for its own + // instance. + uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId, + uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId) { // CC6b-MOUNT: when a caller supplies its own camera instance (the // page-mount composition, which needs a SETTABLE camera for @@ -98,13 +108,13 @@ internal sealed class ChargenPreviewRenderer : lightUbo, textureLifetime, meshAdapter, - ChargenPreviewEntityBuilder.PreviewRenderId, + renderId, _camera, "chargen preview", // Batch D (GF-7/GF-14): reserves the second draw-entity slot for // the heritage's environment Setup — see PrivateEntityViewportRenderer's // own doc comment on backdropRenderId. - ChargenPreviewEntityBuilder.PreviewBackdropRenderId); + backdropRenderId); } public bool TextureIsBottomUp => _renderer.TextureIsBottomUp; diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index 22cb8ca5..f293218b 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -140,6 +140,21 @@ internal sealed class PrivateEntityViewportRenderer : ? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop") : null; + // F14 (Campaign CC gate round 1 closeout): this set is built ONCE + // here, from the RESERVED backdropRenderId (a renderer either has a + // backdrop slot or it doesn't — see _backdropSlot's own doc), not + // from whether a backdrop ENTITY is currently set via + // SetBackdrop/BuildDrawEntities. That is deliberately harmless, not + // an oversight: BuildDrawEntities below already degrades to + // [main] alone whenever the backdrop slot is null or has no + // meshes, so animatedEntityIds carrying a backdrop id with no + // matching entry in THIS frame's actual draw-entities list is a + // pure dead lookup (WbDrawDispatcher.Draw only ever consults this + // set against ids it is ACTUALLY drawing) — never a wrong-entity + // animation flag, never extra per-frame work beyond one inert + // HashSet entry. Recomputing per-frame would add real complexity + // (a second HashSet allocation or a mutable-set sync path) for a + // case that is already correct by construction. _animatedIds = backdropRenderId is uint animatedBackdropId ? [renderId, animatedBackdropId] : [renderId]; diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index c5b77d05..2f90d34d 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -87,12 +87,23 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg : this( new SilkWindowSizeSurface(window), new GlfwDisplayModeSwitcher(window), - // #391's catalog is the validation source. With no catalog - // installed, the dropdown falls back to the static preset - // ladder — the validator must fall back to the SAME list - // (blast M2: an asymmetric fallback made Full Screen a permanent - // silent no-op on catalog-less hosts). The switcher's own - // monitor-mode-list check remains the hard guard either way. + // F9 correction (Campaign CC gate round 1 closeout, 2026-08-16): + // this used to claim the validator "must fall back to the SAME + // list" the Config dropdown offers — true when this comment was + // written (#391, one catalog for both), but #407 split the + // catalog in two: WindowedResolutions (the dropdown's fuller + // union offering, since a windowed pick needs no real video + // mode) versus Resolutions (the narrower, fullscreen-SAFE + // hardware list this validator deliberately reads). Post-#407 a + // windowed-only entry submitted for fullscreen is EXPECTED to + // fail this check and refuse gracefully (log-and-stay, + // #388/#392's own documented behavior) — that is no longer the + // blast-M2 silent-no-op bug, it is the correct outcome. With no + // catalog installed at all (fixture/headless/UI-Studio hosts), + // Resolutions is null and this still falls back to the static + // preset ladder, matching every offering DisplayModeCatalog + // makes in that state. The switcher's own monitor-mode-list + // check remains the hard guard either way. spec => (Rendering.DisplayModeCatalog.Resolutions ?? DisplaySettings.AvailableResolutions).Contains(spec)) { diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index 0a2a03cb..a451a506 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -174,7 +174,13 @@ internal sealed class CharacterCreationHeritagePage : IDisposable IReadOnlyList segments = ComposeSegments( _description, view, snapshot.HeritageId, _bindings.ResolveText); - _description.LinesProvider = () => DatRichText.Compose(_description, segments); + // F11 (Campaign CC gate round 1 closeout): compose ONCE here, inside + // Refresh (already revision-gated by CharacterCreationUiController.Tick + // — this method only runs when something in chargen state actually + // changed), and hand LinesProvider the already-built list instead of + // re-composing (escape-normalize + word-wrap) on EVERY draw call. + IReadOnlyList composed = DatRichText.Compose(_description, segments); + _description.LinesProvider = () => composed; } internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs index 36a69d64..ff3433a6 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs @@ -256,7 +256,12 @@ internal sealed class CharacterCreationProfessionPage : IDisposable { string? text = _bindings.ResolveText?.Invoke(key); var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) }; - _description.LinesProvider = () => DatRichText.Compose(_description, segments); + // F11 (Campaign CC gate round 1 closeout): compose ONCE here + // (Refresh is already revision-gated) instead of re-wrapping on + // every draw call — see CharacterCreationHeritagePage.Refresh's + // own comment for the full rationale. + IReadOnlyList composed = DatRichText.Compose(_description, segments); + _description.LinesProvider = () => composed; } } diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs index 9b9cdb39..8662ffe7 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -286,9 +286,14 @@ internal sealed class CharacterCreationSummaryPage : IDisposable if (builder.Length == 0) return; - string composed = builder.ToString(); - var segments = new[] { new DatRichText.Segment(composed, _howToText.DefaultColor) }; - _howToText.LinesProvider = () => DatRichText.Compose(_howToText, segments); + string composedText = builder.ToString(); + var segments = new[] { new DatRichText.Segment(composedText, _howToText.DefaultColor) }; + // F11 (Campaign CC gate round 1 closeout): compose ONCE here + // (Refresh is already revision-gated) instead of re-wrapping on + // every draw call — see CharacterCreationHeritagePage.Refresh's own + // comment for the full rationale. + IReadOnlyList composedLines = DatRichText.Compose(_howToText, segments); + _howToText.LinesProvider = () => composedLines; } // ── Name field (ListenToElementMessage @ 0x0047bf40) ──────────────── diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs index f2b1be7d..afdea3ea 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs @@ -118,7 +118,12 @@ internal sealed class CharacterCreationTownPage : IDisposable // looked like the text never changed). string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText); var segments = new[] { new DatRichText.Segment(composed, _description.DefaultColor) }; - _description.LinesProvider = () => DatRichText.Compose(_description, segments); + // F11 (Campaign CC gate round 1 closeout): compose ONCE here + // (Refresh is already revision-gated) instead of re-wrapping on + // every draw call — see CharacterCreationHeritagePage.Refresh's own + // comment for the full rationale. + IReadOnlyList composedLines = DatRichText.Compose(_description, segments); + _description.LinesProvider = () => composedLines; } internal void Randomize(IRuntimeCharacterCreationView view) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 4773e5dd..b726df79 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -915,6 +915,22 @@ public static class DatWidgetFactory else { button.LabelAlign = UiButton.LabelAlignment.Left; + // F10 (Campaign CC gate round 1 closeout): this +4f gap and + // UiButton.LabelOffsetX's own class-default 3f (used by the + // "no face, not lifted" branch below, AND by any caller — + // e.g. PaperdollController's "Slots" label — that sets + // LabelAlign=Left directly with no DatWidgetFactory + // involvement at all) are DELIBERATELY not the same number, + // not an unreconciled oversight: neither carries a retail + // decomp citation (both are acdream-synthesized small + // insets), and they answer different questions — this one + // is "gap after a REAL adjacent face element" (a geometry- + // derived offset), the other is "default left inset when + // there is no reference geometry at all" (a context-free + // fallback). Moving either number to match the other would + // be an unfounded 1px guess on whichever button currently + // works, not a fix — see DatWidgetFactoryTests' own + // `face.X(0) + face.Width(32) + 4` pin for this exact site. button.LabelOffsetX = face.X + face.Width + 4f; } } @@ -932,7 +948,10 @@ public static class DatWidgetFactory // Type-12 child, and the built row's LabelAlign came out Center. // labelInfo.X is only a valid inner-offset when a distinct child // was actually lifted; for the direct (labelInfo == info) case, - // leave UiButton's own default 3px LabelOffsetX in place. + // leave UiButton's own default 3px LabelOffsetX in place — see + // the face-relative +4f branch above (F10) for why this 3px + // default and that 4px gap are deliberately different numbers, + // not an unreconciled asymmetry. button.LabelAlign = UiButton.LabelAlignment.Left; if (!ReferenceEquals(labelInfo, info)) button.LabelOffsetX = labelInfo.X; diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 215f3093..bb411eb4 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -189,7 +189,21 @@ public static class LayoutImporter { if (child.StateMedia.Count == 0) continue; var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId); - if (cw is not null) w.AddChild(cw); + if (cw is null) continue; + // F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor + // of AuthoredInvisible, scoped to children reached through + // THIS carve-out only — e.g. the chat new-text indicator + // (0x1000048C, live-DAT-confirmed Invisible=true on every + // layout it appears in) would otherwise render as a phantom + // element retail never shows, now that this carve-out + // builds it as a real widget instead of silently dropping + // it. This is NOT the general client-wide honor (#408, + // 1,083 elements) — every OTHER AuthoredInvisible consumer + // stays data-only, acted on nowhere but chargen's own + // HideAuthoredInvisibleElements walk (register AP-230). + if (cw.AuthoredInvisible) + cw.Visible = false; + w.AddChild(cw); } } diff --git a/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs index 0803a711..009a39d9 100644 --- a/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs +++ b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs @@ -99,7 +99,18 @@ public sealed class BoundedProcessOutputCapture : IDisposable /// Process.ErrorDataReceived line) followed by a newline. Never /// throws. A line (the sentinel .NET's /// ErrorDataReceived raises once when the stream closes) is a - /// silent no-op. + /// silent no-op. + /// + /// + /// F7 (Campaign CC gate round 1 closeout): the text and its trailing + /// newline are combined into ONE buffer and written through ONE + /// call. The class doc's own "every write + /// opens the file fresh" contract means two separate calls (text, then + /// newline) used to open/write/flush/close the file TWICE per logical + /// line — needless I/O for a sink that already fires once per received + /// output line. + /// + /// public void AppendLine(string? line) { if (line is null) @@ -107,10 +118,14 @@ public sealed class BoundedProcessOutputCapture : IDisposable return; } + byte[] textBytes = Encoding.UTF8.GetBytes(line); + var buffer = new byte[textBytes.Length + Newline.Length]; + textBytes.CopyTo(buffer, 0); + Newline.CopyTo(buffer, textBytes.Length); + lock (_gate) { - AppendLocked(Encoding.UTF8.GetBytes(line)); - AppendLocked(Newline); + AppendLocked(buffer); } } diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs index abebc743..68fb5fcb 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -66,6 +66,57 @@ public sealed class ChargenPreviewEntityBuilderTests _out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}"); } + /// + /// F16 (Campaign CC gate round 1 closeout): the Appearance and Summary + /// pages must stamp DIFFERENT + /// values on their preview entities — both feed the SAME shared + /// TextureCache owner-tracking key + /// (ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc + /// has the full collision trace). Pins BOTH halves: the constants + /// themselves are distinct, AND the explicit renderId parameter + /// actually reaches the built entity (not silently ignored). + /// + [Fact] + public void TryBuild_ExplicitRenderId_StampsThatIdOnTheEntity_DistinctFromTheAppearanceDefault() + { + Assert.NotEqual( + ChargenPreviewEntityBuilder.PreviewRenderId, + ChargenPreviewEntityBuilder.SummaryPreviewRenderId); + Assert.NotEqual( + ChargenPreviewEntityBuilder.PreviewBackdropRenderId, + ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId); + + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian. + Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)); + + var catalog = new ChargenAppearanceCatalog(adapter); + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + var animations = new RetailAnimationLoader(adapter); + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object(), + renderId: ChargenPreviewEntityBuilder.SummaryPreviewRenderId); + + Assert.NotNull(entity); + Assert.Equal(ChargenPreviewEntityBuilder.SummaryPreviewRenderId, entity!.Id); + Assert.NotEqual(ChargenPreviewEntityBuilder.PreviewRenderId, entity.Id); + } + [Fact] public void TryBuild_UnknownSetupId_ReturnsNull() { @@ -317,6 +368,36 @@ public sealed class ChargenPreviewEntityBuilderTests _out.WriteLine($"backdropSetup=0x{aluvian.EnvironmentSetupId:X8} meshRefs={entity.MeshRefs.Count}"); } + /// F16 (Campaign CC gate round 1 closeout): the backdrop's own + /// explicit renderId parameter reaches the built entity, the + /// same shape as + /// pins for the main preview entity. + [Fact] + public void TryBuildBackdrop_ExplicitRenderId_StampsThatIdOnTheEntity() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian. + if (aluvian!.EnvironmentSetupId == 0u) + { + _out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId."); + return; + } + + var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop( + adapter, aluvian.EnvironmentSetupId, new object(), + renderId: ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId); + + Assert.NotNull(entity); + Assert.Equal(ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId, entity!.Id); + Assert.NotEqual(ChargenPreviewEntityBuilder.PreviewBackdropRenderId, entity.Id); + } + /// Retail's own gate at 0x004eed29 (if (eax_32 != INVALID_DID.id)) /// skips creating a backdrop object entirely when the heritage authors no /// environment Setup — id 0/unset must return null, not an empty entity. diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs index 97be363d..7970fb2b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterMediaBearingChildSweepTests.cs @@ -86,23 +86,32 @@ public sealed class LayoutImporterMediaBearingChildSweepTests using var dats = new DatCollection(DatDirectory, DatAccessType.Read); // MAIN GAME UI (0x21000005/0x1000059A): the same eight gold-frame - // pieces the chargen boxes carry. + // pieces the chargen boxes carry. F5/F6 closeout: NONE of these + // author Invisible, so all eight must build VISIBLE — the "other + // direction" the reviewer named, pinned here alongside the + // enumeration sweep's own DoesNotContain assertions. AssertChildrenBuild( dats, layoutId: 0x21000005u, elementId: 0x1000059Au, expectedChildIds: [ 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, - ]); + ], + expectedInvisible: []); - // CHAT INPUT (0x2100006F/0x10000011): a single media-bearing child. + // Chat transcript (0x2100006F/0x10000011): a single media-bearing + // child. F5/F6 closeout: 0x1000048C (the new-text indicator) + // authors Invisible=true — must build HIDDEN, not as a phantom + // visible element. AssertChildrenBuild( dats, layoutId: 0x2100006Fu, elementId: 0x10000011u, - expectedChildIds: [0x1000048Cu]); + expectedChildIds: [0x1000048Cu], + expectedInvisible: [0x1000048Cu]); } private static void AssertChildrenBuild( - IDatReaderWriter dats, uint layoutId, uint elementId, uint[] expectedChildIds) + IDatReaderWriter dats, uint layoutId, uint elementId, uint[] expectedChildIds, + uint[] expectedInvisible) { ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); Assert.NotNull(tree); @@ -114,7 +123,10 @@ public sealed class LayoutImporterMediaBearingChildSweepTests Assert.IsType(built); foreach (uint childId in expectedChildIds) { - Assert.NotNull(UiElement.FindDescendant(built, childId)); + UiElement? child = UiElement.FindDescendant(built, childId); + Assert.NotNull(child); + bool shouldBeInvisible = expectedInvisible.Contains(childId); + Assert.Equal(!shouldBeInvisible, child!.Visible); } } @@ -129,6 +141,78 @@ public sealed class LayoutImporterMediaBearingChildSweepTests return null; } + private readonly record struct InvisibleChildFinding(uint LayoutId, uint ParentElementId, uint ChildElementId); + + /// + /// Campaign CC gate round 1 closeout, F5/F6: of the media-bearing + /// children the Batch C carve-out now builds instead of dropping + /// ('s + /// own set), which ones author dat property 0x3B (Invisible) + /// THEMSELVES — retail would never show them + /// (UIElement::OnSetAttribute @0x00462d80 case 8), so building + /// them unconditionally as a visible widget is a regression the + /// carve-out's own commit didn't check for. Confirms the chargen + /// gold-frame pieces are NOT among them (the other direction the + /// reviewer named — Batch A's chargen-scoped hide walk must not eat + /// them either way, but this proves the DATA itself never marks them + /// invisible, independent of which honor mechanism runs). + /// + [InstalledDatFact] + public void MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + var invisibleFindings = new List(); + foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(x => x)) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + if (tree is null) continue; + WalkForInvisibleMediaBearingChildren(layoutId, tree, invisibleFindings); + } + + Console.WriteLine($"[SWEEP-INV] {invisibleFindings.Count} media-bearing children of the " + + "Batch C carve-out author Invisible=true themselves."); + foreach (InvisibleChildFinding f in invisibleFindings) + { + Console.WriteLine($"[SWEEP-INV] layout=0x{f.LayoutId:X8} parent=0x{f.ParentElementId:X8} " + + $"child=0x{f.ChildElementId:X8}"); + } + + // The chargen gold-frame pieces (GF-12) must NOT author Invisible — + // otherwise a narrow per-child honor would eat them, undoing that + // fix. Checked directly against the data, independent of whichever + // honor mechanism runs. + uint[] goldFramePieceIds = + [ + 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, + 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, + ]; + foreach (uint pieceId in goldFramePieceIds) + { + Assert.DoesNotContain(invisibleFindings, f => f.ChildElementId == pieceId); + } + } + + private static void WalkForInvisibleMediaBearingChildren( + uint layoutId, ElementInfo node, List findings) + { + if (node.Type == 12u) + { + bool passToChildren = node.States.Values.Any(static s => s.PassToChildren); + if (!passToChildren) + { + foreach (ElementInfo child in node.Children) + { + if (child.StateMedia.Count > 0 && child.Invisible) + findings.Add(new InvisibleChildFinding(layoutId, node.Id, child.Id)); + } + } + } + + foreach (ElementInfo child in node.Children) + WalkForInvisibleMediaBearingChildren(layoutId, child, findings); + } + private static void Walk(uint layoutId, ElementInfo node, List findings) { if (node.Type == 12u) diff --git a/tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs new file mode 100644 index 00000000..05be8fac --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/UiButtonCorpusSweepTests.cs @@ -0,0 +1,214 @@ +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC gate round 1 closeout, F4: three client-wide blast-radius +/// sweeps over EVERY installed LayoutDesc, walking every Type-1 +/// (UIElement_Button) element and matching the SAME structural +/// predicates uses internally +/// (predicates re-derived here rather than reflected, since the source +/// methods are private — kept in sync by citing the exact source +/// line ranges in each sweep's own doc). Same style as +/// : logs the full +/// enumeration for the commit message, pins landmark counts rather than a +/// brittle exact global total. +/// +public sealed class UiButtonCorpusSweepTests +{ + private static string DatDirectory => + System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + + private readonly record struct ButtonFinding(uint LayoutId, uint ElementId); + + /// + /// Sweep (a): which buttons take the GF-11c LabelBox path — + /// info.StateMedia.Count==0 (no media on the button itself) with + /// EXACTLY one stateful face child, plus a DISTINCT lifted Type-12 + /// caption child (not the button's own P0x17) — see + /// DatWidgetFactory.BuildButton:889-914 for the exact shape this + /// mirrors. + /// + [InstalledDatFact] + public void LabelBoxPath_EnumeratesEveryMatchingButton() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + var findings = new List(); + foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(x => x)) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + if (tree is null) continue; + WalkButtons(layoutId, tree, findings, MatchesLabelBoxShape); + } + + Console.WriteLine($"[SWEEP-A] {findings.Count} buttons take the LabelBox path across " + + $"{findings.Select(f => f.LayoutId).Distinct().Count()} layouts."); + foreach (ButtonFinding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId)) + Console.WriteLine($"[SWEEP-A] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}"); + + // Landmark this campaign already fixed and gated (GF-11c, the Town + // page's per-marker name label) must be in the set — proves the + // sweep's predicate is right, not just non-empty. + Assert.Contains(findings, f => IsTownButton(f.ElementId)); + } + + /// + /// Sweep (b): any button authoring BOTH the custom Unselected/Selected + /// radio-pair (UiButton's _hasCustomSelectionPair bypass, + /// GF-1/GF-8) AND standard Normal/Highlight media — the custom-pair + /// bypass would eat the standard state machine for such a button + /// (UiButton.UpdateVisualState's if (_hasCustomSelectionPair) + /// branch runs UNCONDITIONALLY when the pair is present, never falling + /// through to the standard _availableStates branch). None found + /// in the installed corpus at the ELEMENT's own media level (this sweep + /// does not additionally check face-SEGMENT media — see this method's + /// own note). + /// + [InstalledDatFact] + public void CustomSelectionPair_NeverCoexistsWithStandardNormalHighlightMedia() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + var findings = new List(); + foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(x => x)) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + if (tree is null) continue; + WalkButtons(layoutId, tree, findings, MatchesConflictingPairShape); + } + + Console.WriteLine($"[SWEEP-B] {findings.Count} buttons author BOTH the custom " + + "Unselected/Selected pair AND standard Normal/Highlight media."); + foreach (ButtonFinding f in findings) + Console.WriteLine($"[SWEEP-B] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}"); + + // No conflict exists in the installed corpus today — the + // `_hasCustomSelectionPair` bypass in `UiButton.UpdateVisualState` + // is safe as-is (unconditional-when-present) without needing a + // tighter gate. If a future DAT drop introduces one, this test + // fails here rather than silently regressing that button's + // Highlight/rollover feedback. + Assert.Empty(findings); + } + + /// + /// Sweep (c): any button with a genuine per-state label-color/outline + /// map (AP-222's mechanism, ElementReader.BuildPerStateColorMap/ + /// BuildPerStateBoolMap against dat properties 0x1B/ + /// 0x21 — non-null only when the authored dat carries MORE THAN + /// ONE distinct value across states) beyond the chargen Appearance + /// spins and Town buttons this campaign already ported and gated. + /// + [InstalledDatFact] + public void PerStateLabelColorMap_EnumeratesEveryButtonBeyondChargen() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + var findings = new List(); + foreach (uint layoutId in dats.GetAllIdsOfType().OrderBy(x => x)) + { + ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId); + if (tree is null) continue; + WalkButtons(layoutId, tree, findings, MatchesPerStateLabelStyleShape); + } + + Console.WriteLine($"[SWEEP-C] {findings.Count} buttons carry a genuine per-state " + + "label color/outline map."); + foreach (ButtonFinding f in findings.OrderBy(f => f.LayoutId).ThenBy(f => f.ElementId)) + Console.WriteLine($"[SWEEP-C] layout=0x{f.LayoutId:X8} element=0x{f.ElementId:X8}"); + + // Landmarks this campaign already ported: the nine Appearance spins + // (Hair/Eyes/Skin/Headgear/Shirt/Trousers/Footwear/Nose/Mouth, all + // sharing one Highlight-gold-brightening state pair) and the four + // Town buttons (Normal-gold -> Selected-white caption swap). + Assert.Contains(findings, f => + f.ElementId == CharacterCreationAppearancePage.HairSpinId); + Assert.Contains(findings, f => IsTownButton(f.ElementId)); + } + + /// Town page's four starting-area button ids + /// (CharacterCreationTownPage's own private + /// StartAreaByButtonId keys — no public constants exist there, + /// so the literals are duplicated here). + private static bool IsTownButton(uint elementId) => elementId is + 0x1000040Bu or 0x1000040Du or 0x1000040Eu or 0x1000040Fu; + + // ── Shared predicates (re-derived from DatWidgetFactory.BuildButton) ── + + private static bool MatchesLabelBoxShape(ElementInfo info) + { + if (info.StateMedia.Count != 0) + return false; + ElementInfo[] faces = FindStatefulFaceChildren(info); + if (faces.Length != 1) + return false; + + // A DISTINCT lifted Type-12 caption child (not the button's own + // P0x17) — DatWidgetFactory.BuildButton's own "label is null on the + // button itself, found on a Type-12 child instead" fallback. + bool ownCaption = HasStringInfoProperty(info); + if (ownCaption) + return false; + return info.Children.Any(child => child.Type == 12u && HasStringInfoProperty(child)); + } + + private static bool MatchesConflictingPairShape(ElementInfo info) + { + bool hasCustomPair = info.StateMedia.ContainsKey("Unselected") && info.StateMedia.ContainsKey("Selected"); + bool hasStandardPair = info.StateMedia.ContainsKey("Normal") || info.StateMedia.ContainsKey("Highlight"); + return hasCustomPair && hasStandardPair; + } + + private static bool MatchesPerStateLabelStyleShape(ElementInfo info) + { + // Mirror BuildButton's labelInfo resolution: the button's own P0x17 + // if present, else the first Type-12 child with a resolvable one. + ElementInfo labelInfo = HasStringInfoProperty(info) + ? info + : info.Children.FirstOrDefault(child => child.Type == 12u && HasStringInfoProperty(child)) ?? info; + + return ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu) is not null + || ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u) is not null; + } + + private static bool HasStringInfoProperty(ElementInfo info) => + info.TryGetEffectiveProperty(0x17u, out UiPropertyValue property) + && property.Kind == UiPropertyKind.StringInfo; + + /// Verbatim copy of DatWidgetFactory.FindStatefulFaceChildren + /// (private there) — a child whose media state names intersect the + /// PARENT's own declared state names. + private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info) => + [.. info.Children + .Where(child => + child.StateMedia.Count != 0 + && child.StateMedia.Keys.Any(childState => + info.States.Values.Any(parentState => + string.Equals(parentState.Name, childState, StringComparison.Ordinal)))) + .OrderBy(child => child.ReadOrder)]; + + private static void WalkButtons( + uint layoutId, + ElementInfo node, + List findings, + Func predicate) + { + if (node.Type == 1u && predicate(node)) + findings.Add(new ButtonFinding(layoutId, node.Id)); + + foreach (ElementInfo child in node.Children) + WalkButtons(layoutId, child, findings, predicate); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs index 5ad94363..7d13e5fb 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/BoundedProcessOutputCaptureTests.cs @@ -81,6 +81,36 @@ public sealed class BoundedProcessOutputCaptureTests } } + /// F7 (Campaign CC gate round 1 closeout): AppendLine + /// now combines the text and its trailing newline into ONE buffer + /// before writing, instead of two separate file open/write/close + /// round-trips. Pins the boundary case that change touches most + /// directly — a line whose TEXT ALONE exactly exhausts the remaining + /// cap, so the newline byte must be dropped by the SAME truncation + /// decision as the text, not a second one. + [Fact] + public void ALineWhoseTextExactlyExhaustsTheCap_DropsOnlyTheTrailingNewline() + { + string path = TempPath(); + try + { + // "0123456789" is exactly 10 bytes; maxBytes=10 leaves no room + // for the newline the combined buffer also carries. + using var capture = new BoundedProcessOutputCapture(path, maxBytes: 10); + + capture.AppendLine("0123456789"); + + Assert.True(capture.IsDone); + string written = File.ReadAllText(path); + Assert.StartsWith("0123456789", written, StringComparison.Ordinal); + Assert.Contains("truncated at 10 bytes", written, StringComparison.Ordinal); + } + finally + { + TryDelete(path); + } + } + [Fact] public void ALogSpammingChildCannotGrowTheFileUnboundedly() { From 9efcd80e34116420b53a377106120aab0e66418d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 15:37:46 +0200 Subject: [PATCH 128/138] =?UTF-8?q?docs:=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20closeout=20=E2=80=94=20register/ISSUES/ledger=20bookkeeping?= =?UTF-8?q?=20(F3,=20F12,=20F15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-only findings from the round review, plus the register rows the three code commits' own bookkeeping notes reference: - F3: AP-229 amended with the dialog-as-sibling z-order addendum — the same flat-sibling-list mechanism that motivates AP-229's own screen- layering row also covers RetailDialogFactory's open dialogs, which was GF-15's actual root cause (now fixed, but the underlying divergence — dialogs and screens sharing one z-order list at all — remains and could reintroduce the same failure class via a future sibling's own unconditional per-tick BringToFront). - F5/F6: AP-230 amended with the second narrow-honor addendum (the LayoutImporter carve-out fix landed in the Group 3 code commit); the findings doc's "CHAT INPUT" label corrected to "chat transcript" in both places it appeared (0x2100006F/0x10000011 is the transcript display, not the input textbox). - F12: the AD section header recounted 77 -> 79 (a direct physical count found it undercounted by 2); the AP section header's own "one high" drift-direction note corrected to "one low" — verified against the actual commit history (Batch A ended with 165 physical rows but a 164 header; Batch B's recount correctly landed on 164, the header was never overcounting). - F15: ISSUES.md #406 gains the crash-vs-incomplete-shutdown precedence sentence — ReportExited's _runFailure check runs first and returns immediately, so a crash always wins over a subsequently-failed shutdown for the same session's reported reason. - AP-231 filed (the Group 2 commit's own ComposeFormula connector-text approximation — referenced in that commit's message but the register row itself was missed until this pass; 161 active AP rows). - Campaign CC plan ledger gains a "Gate round 1" row with the full commit list for batches A-G plus this session's three closeout commits, superseding the ledger's stale "sole remaining acceptance step" framing (written before the connected gate ran and found the GF-1..GF-16 / R2-1..R2-8 findings this whole round fixed). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 13 ++++++++++++- .../architecture/retail-divergence-register.md | 12 +++++------- .../2026-08-15-character-creation-campaign.md | 1 + ...6-08-16-campaign-cc-gate-round1-findings.md | 18 ++++++++++++++++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7c9515a2..b4848f4a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -188,7 +188,18 @@ free-text `reason` field (§LA1's `exited{code,reason}` vocabulary pins the EVENT name, not an enum of `reason` strings — `StatusEventParser` already round-trips any string there) so no wire-contract amendment was needed. Pinned as a source-shape test (`GameWindowCrashStatusTests`) since -`GameWindow` cannot be constructed without a live GPU/window. +`GameWindow` cannot be constructed without a live GPU/window. **Precedence +(F15, gate round 1 closeout, 2026-08-16):** `ReportExited`'s `_runFailure` +check runs FIRST and returns immediately, so a crash ALWAYS wins over an +incomplete shutdown for the same session: if `Run()` observed an +exception AND the resource-shutdown transaction subsequently failed to +converge (`report.Status != Complete`), the reported reason is still +`"crashed"`, never `"shutdown-incomplete"`. The teardown failure itself is +not lost -- `Console.Error.WriteLine` still logs the blocked stage and +every cleanup failure right before `ReportExited` runs -- but the ONE +terminal status event a launcher/monitoring consumer reads only ever +carries one reason per session, and a crash is judged the more actionable +of the two. Sibling gap fixed in the same commit: the launcher previously discarded the child's stdout/stderr entirely, which is why diagnosing this exact diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9f756e21..9c9ce5e9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 77 active rows (AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 79 active rows (F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -198,7 +198,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 163 active rows (AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one high relative to the physical table — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change; AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 161 active rows (AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -206,8 +206,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-230 | **Filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13).** Retail's `UIElement::OnSetAttribute @0x00462d80` case 8 (`GetPropertyName()-0x33==8`, property id `0x3B`, "Invisible") hides ANY element authoring that property `true` via `SetVisible(value==0)` — a general, importer-level mechanism. The blast-radius sweep this fix's investigation ran found **1,083 elements client-wide** author `P0x3B=true` (the Summary page's GM-only `0x10000403`/`0x10000494` labels among them — the user-reported "-Non-admin or Non-envoy" leak). Honoring the flag client-wide in `LayoutImporter`/`DatWidgetFactory` is its own separately-gated visual sweep (docs/ISSUES.md #408, since a mis-hidden element among 1,083 untested ones would silently vanish a control nobody asked to disappear); this fix instead reads the flag as a PURE DATA ADDITION (`ElementInfo.Invisible`, `UiElement.AuthoredInvisible` — populated everywhere, acted on nowhere by the shared path) and only the chargen screen's own mount (`CharacterCreationUiController.HideAuthoredInvisibleElements`, called once at construction) walks its own subtree and hides whatever the dat itself marked hidden. | `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.Invisible`, `ApplyCanonicalLegacyProjection`'s `0x3Bu` read); `src/AcDream.App/UI/UiElement.cs` (`AuthoredInvisible`); `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget`'s passthrough assignment); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`HideAuthoredInvisibleElements`) | The scoped fix closes the ONE reported, live-DAT-confirmed symptom (chargen's two GM labels) without touching any of the other 1,083 elements' visibility, each of which needs its OWN visual gate before the general importer-wide honor can ship safely — narrowing blast radius to a screen this same gate round is already re-testing end-to-end. | Every OTHER screen with an authored-invisible element still renders it (the general honor is #408, not yet shipped) — this row and #408 both retire together once the general sweep lands and passes its own visual gate. | `UIElement::OnSetAttribute @0x00462d80` (case 8, `SetVisible(value==0)`) | -| AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | +| AP-231 | **Filed 2026-08-16 at the Campaign CC gate round 1 closeout, Group 2 (Skills page info-box completion).** `CharacterCreationSkillsPage.ComposeFormula` ports `gmCGSkillsPage::MakeSkillFormula @0x00480e10` with HIGH CONFIDENCE for the `"Formula : "` prefix, the per-attribute `"(%u x %s)"`-vs-bare-name choice (a term's own multiplier > 1 gets the parenthesized form, else just the attribute name), the `" / %u"` divisor suffix (gated on `Divisor != 1`), and the `" +%u"` additive-bonus suffix (gated on `AdditiveBonus != 0`) — every one of those is a directly-read compiled string literal or a field the DatReaderWriter binding already exposes by name (`SkillFormula`'s six fields map 1:1 onto the decompiled struct's own `_w/_x/_y/_z/_attr1/_attr2` offsets, confirmed by their exact 0x28/0x2c/0x30/0x34/0x38/0x3c stride). LOWER CONFIDENCE: the CONNECTOR text between a two-attribute formula's two terms. This port renders `" + "` — the well-known "(Attr1 + Attr2) / N" shape most published AC skill formulas use — but the decompiled function's own two candidate connector literals (`data_7a01a4`, appended between the terms; `data_797584`, appended again immediately after BOTH terms are present, an apparently redundant second literal whose exact role this session could not resolve) sit behind reference-counted `PStringBase` appends whose actual wide-character content Binary Ninja's HLIL does not surface as a literal — this session had no live cdb attach and no running Ghidra MCP instance to recover the raw bytes. A two-attribute skill's formula therefore renders as `"Formula : (2 x Strength) + Endurance / 4 +2"`-shaped text that is very likely retail-correct in STRUCTURE but not byte-verified. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`ComposeFormula`, `AppendAttributeTerm`) | The single-attribute majority of skills render byte-correct today; only the minority of two-attribute formulas carry the unverified connector, and the gap is disclosed in the method's own doc rather than silently guessed. | A live retail capture of a two-attribute skill's formula text (e.g. via the cdb toolchain) could reveal `" + "` is wrong — the actual connector might be `" and "`, `" / "` (an OR-style formula, common for some AC skills that use whichever attribute is higher), or something else the two unresolved literals encode; `data_797584`'s role (appended after both terms) is also unexplained and could indicate a THIRD text segment this port omits entirely. | `gmCGSkillsPage::MakeSkillFormula @0x00480e10`; `SkillFormula` struct (`acclient.h`) | +| AP-230 | **Filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13).** Retail's `UIElement::OnSetAttribute @0x00462d80` case 8 (`GetPropertyName()-0x33==8`, property id `0x3B`, "Invisible") hides ANY element authoring that property `true` via `SetVisible(value==0)` — a general, importer-level mechanism. The blast-radius sweep this fix's investigation ran found **1,083 elements client-wide** author `P0x3B=true` (the Summary page's GM-only `0x10000403`/`0x10000494` labels among them — the user-reported "-Non-admin or Non-envoy" leak). Honoring the flag client-wide in `LayoutImporter`/`DatWidgetFactory` is its own separately-gated visual sweep (docs/ISSUES.md #408, since a mis-hidden element among 1,083 untested ones would silently vanish a control nobody asked to disappear); this fix instead reads the flag as a PURE DATA ADDITION (`ElementInfo.Invisible`, `UiElement.AuthoredInvisible` — populated everywhere, acted on nowhere by the shared path) and only the chargen screen's own mount (`CharacterCreationUiController.HideAuthoredInvisibleElements`, called once at construction) walks its own subtree and hides whatever the dat itself marked hidden. **Second narrow honor added (F5/F6, gate round 1 closeout, 2026-08-16):** `LayoutImporter.BuildWidget`'s Batch C `UiText or UiField` un-consumed-children carve-out now ALSO honors `AuthoredInvisible`, scoped to exactly the children it builds through that one loop — a live-DAT sweep found the chat transcript's new-text indicator (`0x1000048C`) is one of the 37 carve-out (layout, element) pairs' children and authors `Invisible=true` itself, so the carve-out was building it as a visible phantom element retail never shows. Verified in both directions (`MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible` + `MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets`): the chat indicator now builds hidden, and the eight gold-frame pieces this carve-out ALSO covers do not author Invisible and stay visible. Still narrower than #408: only these two honor sites exist (chargen's own screen walk; this one carve-out loop) — every OTHER AuthoredInvisible-bearing element client-wide, reached through the ordinary generic-container recursion, remains data-only. | `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.Invisible`, `ApplyCanonicalLegacyProjection`'s `0x3Bu` read); `src/AcDream.App/UI/UiElement.cs` (`AuthoredInvisible`); `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget`'s passthrough assignment AND the `UiText or UiField` carve-out's own honor); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`HideAuthoredInvisibleElements`) | The scoped fix closes the ONE reported, live-DAT-confirmed symptom (chargen's two GM labels) without touching any of the other 1,083 elements' visibility, each of which needs its OWN visual gate before the general importer-wide honor can ship safely — narrowing blast radius to a screen this same gate round is already re-testing end-to-end. | Every OTHER screen with an authored-invisible element still renders it (the general honor is #408, not yet shipped) — this row and #408 both retire together once the general sweep lands and passes its own visual gate. | `UIElement::OnSetAttribute @0x00462d80` (case 8, `SetVisible(value==0)`) | +| AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. **Dialog-as-sibling addendum (F3, gate round 1 closeout, 2026-08-16):** the same flat-sibling-list mechanism that motivates this row ALSO covers `RetailDialogFactory`'s own open dialogs — a dialog's root is a direct sibling of the chargen/character-management screen roots under the SAME `Host.Root`, and `RetailWindowManager.BringToFront` is a simple "highest ZOrder among siblings + 1", so whichever sibling's own `BringToFront` call runs LAST in a frame wins z-order. This was GF-15's actual root cause (a dialog opened while chargen is active got buried the very next frame because the screen's own per-tick `BringToFront` ran after the dialog's one-time open-time raise) and is now closed by `RetailDialogFactory.Tick()` re-raising every open dialog, in `_openOrder`, every tick — but the underlying divergence (dialogs and screens sharing one z-order list at all, where retail's dialog layer is architecturally separate from `UIFlow`'s single current framework) remains; any FUTURE sibling that calls its own unconditional per-tick `BringToFront` could reintroduce the same failure class against a dialog OR against chargen itself. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | | AP-228 | **Filed 2026-08-16 at the CC5 re-review residual round (R4).** The Summary listbox's skill-row KEY (the skill's display name) sources from `ItemAppraisalTextFormatter.SkillName(int)` — a hardcoded English `switch` over the 54 skill ids — where retail's own `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` builds that same key from the DAT-sourced `SkillBase->_name` field via a `%hs` format substitution (`data_79f3f0`, `0x0047b90f`-`0x0047b915`). Same divergence CLASS as AP-226 (a hardcoded acdream string standing in for a DAT-sourced retail field) but the polarity is REVERSED: AP-226 is retail-static-vs-acdream-DAT-sourced, while here retail is the DAT-sourced side and acdream is the hardcoded side. The identical pattern is ALSO present at a second call site, CC4's Skills page (`CharacterCreationSkillsPage`), which builds its own row labels through the SAME `ItemAppraisalTextFormatter.SkillName` call — not a second, independent divergence, the same one surfacing twice. | `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs` (`SkillName`), consumed by `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`AddSkillBucket`) and `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` | `SkillName` already backs every OTHER retail skill-name surface acdream has shipped (item-appraisal skill lines, wield-requirement text, usage-limit text — `ItemAppraisalTextFormatter`'s whole existing surface) — the Summary/Skills chargen pages reusing it keeps one skill-name source across the client instead of introducing a second, DAT-reading one for chargen alone. English-only is consistent with the rest of the client's current localization posture (no other surface reads a localized skill name from the DAT either). | A non-English or modded DAT install would show its real, localized skill names on retail's character sheet and item-examine windows but acdream's chargen Summary/Skills pages would keep showing the hardcoded English name regardless — a localization-only divergence, never a wire or gameplay difference (the skill id sent over the wire is unaffected). | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` (`data_79f3f0`, `%hs` substitution `0x0047b90f`-`0x0047b915`) | | AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | | AP-226 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 (the Summary listbox's Profession/Gender/Heritage/Starting Town label sources).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` sources these four labels from four STATIC wide-string tables baked into the binary's data section: `pcProfessions[0x7] @ 0x008191a8` ("Custom", "Bow Hunter", "Swashbuckler", "Life Caster", "War Mage", "Wayfarer", "Soldier"), `pcGender[0x3] @ 0x008191c4` ("?", "Male", "Female"), `pcHeritage[0x5] @ 0x008191d0` ("?", "Aluvian", "Gharu'ndim", "Sho", "Viamontian"), `pcTown[0x4] @ 0x008191e4` ("Holtburg", "Shoushi", "Yaraq", "Sanamar") — each indexed directly by the character's `template_`/`mGender`/`mHeritageGroup`/`startArea` field, each guarded by an upper-bound-only range check (`template_ <= 6`, `mGender <= 2`, `mHeritageGroup <= 4`, `startArea <= 3`) with NO append at all when the index is out of range. Concretely: **`pcHeritage`'s guard is `mHeritageGroup <= 4` — heritage ids 5 and above (every NON-HUMAN heritage: Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, Olthoi, OlthoiAcid) are never appended, so retail's own Summary page renders a BARE `"Heritage: "` with no name at all for a non-human character** — a genuine retail quirk, not a decompiler artifact (confirmed by the same guard shape on all four tables). `CharacterCreationSummaryPage`'s port instead sources every label from the already-loaded `ChargenOptions` DAT model (`heritage.Templates[i].Name`, `gender.Name`, `heritage.Name`, `options.StarterAreas[i].Name`) and prints the literal `"None"` when the index is unresolved, for EVERY heritage including non-human ones — never a bare label. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`ProfessionName`, `GenderName`, `RebuildListbox`'s `"Heritage: " + heritage.Name`, `StarterAreaName`) | The DAT-sourced names are the SAME strings a player already sees on every earlier chargen page (Heritage/Profession/Town pages all source from the identical `ChargenOptions` model) — reusing them keeps the Summary page internally consistent with the rest of the screen rather than introducing a second, static, English-only label source that could drift from the DAT (localization, a modded heritage table) or blank out for heritages retail's own hardcoded table never anticipated. | A pixel-level side-by-side against retail would show a non-human character's Summary "Heritage:" row completely empty of a name in retail (an accepted retail bug/limitation) versus acdream always showing the real heritage name — a cosmetic improvement, never a correctness or wire-format difference; a non-English/modded DAT install could theoretically show acdream a label retail's hardcoded English table never had, which is again strictly more informative, not less. | `pcProfessions[0x7] @0x008191a8`; `pcGender[0x3] @0x008191c4`; `pcHeritage[0x5] @0x008191d0`; `pcTown[0x4] @0x008191e4`; `gmCGSummaryPage::SetSummaryText @0x0047b1d0` (the four guard+append sites) | @@ -396,12 +397,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED; RE-NARROWED 2026-08-16 at Batch C fix (GF-6/AP-218) — the "1-based ordinal" framing of item 2 is now STALE and replaced below.** What CLOSED at Batch B: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest. What CLOSED at Batch C: `SetStyleSpinLabel`'s 1-based-ordinal substitution is GONE — `RefreshSpinCaptions` now writes retail's own heritage-flavored STATIC caption (see AP-218, RETIRED). **Still open (RESTATED, not the same gap the ordinal covered):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) now show the SAME static caption regardless of which style is selected — retail's own per-choice visual feedback there is an ICON THUMBNAIL this port still doesn't render (no icon-texture pipeline is wired to ANY chargen widget); the live 3D preview is the player's only feedback for which style is currently active. The four clothing spins (headgear/shirt/trousers/footwear) show a real name via `ChargenGearOption.Name` and have no icon gap. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED Batch B; `RefreshSpinCaptions`, static-caption-only, icon gap still open) | An icon-texture pipeline for the four icon-only spins is new UI infrastructure this round's scope doesn't otherwise need; the static caption alone is retail-faithful for the TEXT half. | A pixel-level side-by-side against retail would show no icon thumbnail next to the four icon-only spins' caption (cosmetic gap only — the caption text itself is now byte-correct, and the live 3D preview still shows the actual selection). A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | -| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1); PARTIALLY CLOSED 2026-08-16 at Batch C (the beyond-count half); REWRITTEN 2026-08-16 at the Campaign CC gate round 1 Batch G fix (R2-5) — the "actual color" half is now IMPLEMENTED AND TESTED, with two narrow STOPPED items outside this batch's file contract before it is visually live.** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed in `SetSelection @0x0047e260` into `m_tColorWheel[i].iRed/iGreen/iBlue` via `ClientCharGenState::GetColorFromPal @0x00563990`, a direct `Palette::get_color32`/`ARGB[index]` read at a fixed per-part sample index — `0xd0` Hair, `0xb0` Nose/Mouth/Skin, `0x103` Eyes, `0x520` Headgear/Shirt/Trousers/Footwear) and blits blank art for any swatch beyond the current part's real color count. **What CLOSED at Batch C:** the "beyond the count" half (`Visible=false` past `ColorCount`). **What Batch G ADDS:** the full palette-to-RGB pipeline — a new pure Core resolver (`ChargenSwatchColorResolver`: PalSet-averaged shape for Hair/Nose+Mouth+Skin/Headgear/Shirt/Trousers/Footwear, direct shape for Eyes, plus the clothing-swatch PalSet lookup through the CURRENTLY EQUIPPED garment's own ClothingTable) backed by a new `ChargenAppearanceCatalog.TryGetColor` (Content) reading real Palette dat objects, pinned against the installed EoR dat (`ChargenAppearanceCatalogColorTests` — e.g. Aluvian male Eye swatch 0 measures RGB(15,63,93), the shared skin PalSet measures a plausible RGB(182,148,118) flesh tone). `CharacterCreationAppearancePage` now computes all nine swatches' colors on every refresh (part change / color change / heritage change — `CharacterCreationAppearancePageSwatchColorTests`) and paints them via a new `ChargenSwatchColorTile` child element added on top of each swatch button. **Two STOPPED items remain, both outside this batch's file contract:** (1) the new `PalSetSource`/`ClothingTableSource`/`PaletteColorSource` late-bound properties (mirroring the existing `PreviewControl` seam) are never assigned by the composition root — until `CharacterCreationUiController.cs` wires a `ChargenAppearanceCatalog` instance into them (a 3-line addition, same shape as the existing `AppearancePreviewControl` wiring), the mechanism stays fully inert and every swatch shows ONLY its authored static art, exactly like before this batch (`UnwiredSources_LeaveEveryTileInvisible` pins this explicitly). (2) `ChargenSwatchColorTile` paints a FLAT color fill (`UiRenderContext.DrawFill`), not a genuine recolored sprite — neither `UiButton` (sealed) nor `UiDatElement` exposes a per-instance `Tint` on its existing `DrawSprite` calls (which DO already carry a `Vector4 tint` parameter the retained-UI shader multiplies against, matching retail's own `Blit_Multiply`); adding one is a small additive change to those two shared widget files this batch does not make. | `src/AcDream.Core/CharGen/ChargenSwatchColor.cs` + `ChargenSwatchColorResolver.cs` (new); `src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs` (`TryGetColor`, new); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`ComputeSwatchColors` + the swatch loop, now paints real color OR stays inert without the wiring); `src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs` (new) | Everything reachable inside this batch's file contract (Core resolver, Content palette read, the App page's own computation + rendering primitive) is fully implemented and tested; the two remaining gaps are BOTH shared-file edits (composition-root wiring; a widget Tint property) outside that contract, reported as STOPPED items rather than worked around. | Until the STOPPED composition-root wiring lands, a user still sees the pre-Batch-G static swatches (this batch changes nothing observable on its own). Once wired, every VALID swatch will show a flat-color patch at its own computed RGB rather than retail's recolored dot-shaped sprite — correct COLOR, approximated SHAPE, until the second STOPPED item (the widget Tint property) also lands. | `gmCGAppearancePage::DoColorSpots @0x0047d850`; `gmCGAppearancePage::SetSelection @0x0047e260`; `ClientCharGenState::GetColorFromPal @0x00563990`; `Palette::get_color32 @0x0053e050`; `CharGenState::StoreColorInformation @0x005c44d0`; `CharGenState::SetHeadgearStyle @0x005c5350` | -| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 (R3); PARTIALLY CLOSED 2026-08-16 at Batch C (the Eyes-blank half); REWRITTEN 2026-08-16 at the Campaign CC gate round 1 Batch G fix (R2-5) — the gradient-TINT half is now IMPLEMENTED AND TESTED, same two STOPPED items as AP-216 (they share the same underlying pipeline and rendering primitive).** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch has NO case for the GradCircle (`0x1000030e`) — it is not a click target. `DoGradDisk @0x0047da90` is PAINT-only, called from `SetColor`'s tail (`@0x0047de18`, AFTER `m_iCurColor` is updated) and from `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it tints the gradient graphic with `m_tColorWheel[m_iCurColor]`'s OWN color for every part except Eyes, or blits the blank "grad plug" for Eyes. **What CLOSED at Batch C:** the Eyes-blank half. **What Batch G ADDS:** the tint half, through the SAME `ChargenSwatchColorResolver`/`ChargenSwatchColorTile` machinery AP-216 now has — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` picks the color at the CURRENTLY SELECTED swatch index (index 0, unconditionally, for Nose/Mouth/Skin — retail hard-codes `eyeColor = 0` for those three cases in `SetSelection`) and paints the GradCircle's own tile with it; Eyes stays permanently untinted (`EyesPart_GradientDiscTileStaysBlank`), and the two STOPPED items from AP-216 (composition-root wiring; a genuine `UiButton`/`UiDatElement` sprite-tint property in place of the current flat-fill approximation) block this half from being visually live for the identical reason. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s GradCircle tint block; `_gradCircleTile`) | Same rationale as AP-216 — the full data pipeline is in place and tested; only the two shared-file STOPPED items (outside this batch's contract) remain before either half renders on screen. | Same STOPPED-item gating as AP-216: no observable change until the composition-root wiring lands; once wired, the disc shows a flat tint rather than retail's recolored gradient graphic until the widget Tint property also lands. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | -| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. **Batch F investigation (Campaign CC gate round 1, 2026-08-16 — R2-4b): the remaining flat-list-vs-four-bucket half's blocker is now PRECISELY IDENTIFIED, still NOT implemented.** Retail's `UpdateSkillEntry @0x00480bf0` splits Untrained-class rows into UseableUntrained/UnuseableUntrained via `arg2->iMinlevel <= 1` — `iMinlevel` copies `SkillBase._min_level` (portal.dat SkillTable, live-DAT-confirmed present and populated — `SkillTable_MinLevelDistribution_NeverExceedsTrained` measures 23 skills at MinLevel=1, 15 at MinLevel=2 in the installed dat). `AcDream.Core.CharGen.ChargenOptions`/`ChargenHeritageOptions`/`ChargenSkillCost` carry per-skill COSTS only; MinLevel is not threaded through CC1's `ChargenTableReader` at all, and `CharacterCreationRuntimeBindings` has no resolver for it (unlike `GetSkillScore`, which already exists for the analogous per-skill score lookup, `AcDream.App.Net.ChargenSkillScoreResolver`). Closing this row for real needs: (1) `ChargenSkillCost` (or a sibling record) gains a `MinLevel`/useable-while-untrained field, (2) `ChargenTableReader.Load` populates it from `SkillBase.MinLevel`, (3) the page reads it directly (`ChargenOptions` is already reachable from `CharacterCreationSkillsPage`, no new binding needed once (1)/(2) land) to build the four header rows (`Templates[0]`, `0x100002F4` — its own caption child `0x100002f6` live-DAT-measured as a `UiButton`, read via `.Label`, not a `UiText` — the same `UIElement_Button`-is-`DynamicCast(0xc)`-compatible-with-Text quirk GF-4b already ported) and re-bucket on every level change (`InsertEntrySorted`'s own alphabetical-by-name category-relative insert). Batch F separately fixed two ADJACENT bugs found while re-deriving `SetSkillText`'s full per-branch behavior for this SAME row (review F1/F2 — plain bugs, not divergences, so no register rows of their own): the Untrained-down/Specialized-up literal-`"0"`-vs-blank cost text, and the `pSkillUpButton`/`pSkillDownButton` Ghosted/Enabled state pair (gated on `remainingSkillCredits` and a re-derived `bUntrainable`/`bUnspecializable` — the row's OWN effective trained/specialized cost being nonzero — using cost data this page already resolves, no new channel needed for those two). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`, `SelectRow`, `RefreshInfoBox`); `src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs` (`ChargenSkillCost` — the field that would need to grow); `src/AcDream.Content/CharGen/ChargenTableReader.cs` (the read site that would need to populate it) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. Separately, a player cannot yet tell "Useable Untrained" from "Unuseable Untrained" (both render identically, ungrouped) until the `MinLevel` channel above is wired — a second, narrower discoverability gap layered on the first. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0`; `gmCGSkillsPage::SetSkillText @ 0x00480600`; `gmCGSkillsPage::ShowSkillsText @ 0x00481250`; `gmCGSkillsPage::MakeSkillFormula @ 0x00480e10` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 254d6481..8ac616e9 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -286,3 +286,4 @@ the user gate. | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | REVIEW-CLOSED 2026-08-16 | `9cf6c522`, `ddcbf1fb`, F1-F9 review-fix round `2176ba76` | CLOSED (dual-lens: both lenses PASS-with-items → F1-F9 fix round this commit; lead diff-check close per the doc/test-only residual pattern) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. **Corrected at the CC7 review-fix round, F7 (2026-08-16): the original fixture-ordering claim above was WRONG.** The shared fixture originally constructed chargen FIRST so its `Open` method existed to wire into management's `RequestCreate` binding — the OPPOSITE of production's real tick order (`RetailUiRuntime.Tick`: `_characterManagementMount?.Tick(); CharacterManagementController?.Tick(); _characterCreationMount?.Tick(); CharacterCreationController?.Tick();` — management always ticks first). The fixture now constructs management first, handing it a lazily-resolved closure over chargen's not-yet-existing `Controller.Open` — the SAME trick production's own `RetailUiRuntime.ConfigureCharacterManagement` uses (`bindings with { RequestCreate = () => CharacterCreationController?.Open() }`) — matching production's real construction AND tick order instead of contradicting it. The test also now asserts `Chargen.Controller.Root.ClickThrough == false` and a strictly higher `ZOrder` than management's root once both controllers have ticked with chargen open, pinning the `BringToFront` occlusion effect the reviewer had previously verified only by manual inspection. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum. **Corrected at the CC7 review-fix round, F6 (2026-08-16): the checksum half of that claim overstated what the assertion proves.** `Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum)` (`LiveSessionControllerCharacterCreationTests.cs:537`) is a round-trip/purity check — it computes the SAME production `CharacterCreate.ComputeChecksum` on both the encode and the decode side, not an independent golden value. It still closes the one gap (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 non-checksum fields were previously unverified); the checksum's actual golden value lives separately at `CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet` (the 19-term sum, golden `205u`), now cross-referenced from this test's own doc comment. `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). **Review fix round (this commit, F1-F9), CC7 REVIEW-CLOSED:** F1 files AP-229 for the screen-layering divergence the reviewer flagged (retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch; acdream keeps both `CharacterManagementUiController`/`CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes), records what the reviewer confirmed already works (selection/world-name persistence, click-through isolation, one coherent `Modal` stack), and the narrow residual risk it left open (the shared `RetailDialogFactory` can hand `UiRoot.Modal` to a dialog opened by the still-ticking, occluded management screen's `ReconcileDialogs` on an inbound `CharacterError` — a race retail cannot have since the occluded screen simply does not exist there). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe (ACE default 11, confirmed against `references/ACE/Source/ACE.Server/Command/Handlers/AdminCommands.cs:4393`) and the pending-delete-counts-too note. F3 adds AP-221's exact console-diagnostic lines to §CC2's known-gaps paragraph so a session-permanent dead preview reads as a known gap, not a fresh bug. F4 adds an empty-name/AP-227 step to §CC3 so the tester expects acdream's `NoNameWarning` dialog instead of retail's silent keep-old-name behavior. F5 adds an App-layer source-text pin (`GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter`) for the `CharacterCreated`/`CreationFailed` delegate wiring inside `LiveSessionRuntimeFactory.cs:229-236` the reviewer proved was deletable without breaking any test — no practical seam exists to construct the factory end-to-end without a `GameWindow` (confirmed: its one production construction site is deep inside `SessionPlayerComposition.cs`, and no test in the repo constructs it directly), so the pin follows this same test file's own established source-text pattern (`ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot`, `DisplacedLifecycleBodiesAreAbsent`) rather than a contrived full construction; the exact payload SHAPE these delegates produce was already pinned separately at `SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape`, so the new test plus that existing one together cover "bound" and "correct payload." F6/F7 correct this row's own wording above (checksum-assertion circularity; fixture construction order) and strengthen `CharacterScreensFixedCanvasArbiterTests` per F7's fix. F8 records a known flake found under full-solution parallel load on both reviewer runs (passes standalone, unrelated to CC7 — an allocation assertion sensitive to concurrent load): `AcDream.Runtime.Tests.Physics.RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, joining the existing Core.Net NakEmission / Content DecodedTextureCache / App SocialPanelLiveMountProbeTests known-flake set. F9 adds a one-line note to §CC2's Heritage-page Random step that a uniform pick over 13 heritages can repeat the current one. **Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate** (`docs/research/2026-08-16-campaign-cc-test-script.md`) — no automated live character creation has touched ACE yet; that gate remains the sole outstanding acceptance step. | | CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, `d2a71152`, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round `d2a71152` → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of `d2a71152` (R3) after re-deriving from the decomp: `gmCGAppearancePage::ListenToElementMessage`'s own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — `DoGradDisk` is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever `SetColor`/`SetSelection` run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; **the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of `d2a71152`, N2)** — none of the nine spins author Highlight-state media, so `RefreshColorAndShadeControls`'s `TrySetRetailState(Highlight)` call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. | +| Gate round 1 | CLOSED 2026-08-16 (batches A-G plus a dedicated closeout round; supersedes this ledger's "sole remaining acceptance step" framing above — that framing predates the user's connected gate, which found the six-page findings batch GF-1..GF-16, then re-tested and found R2-1..R2-8, both fully fixed across this round) | Batches: `1d9de5e0` (A — GF-15 input/GF-5 skills rows/GF-13 GM toggles), `7d09821f` (B — authored selection states/label state/zoom-swatch feedback), `0591b9a0`+`5190e169`+`2349f8b4` (C — rich text/labels/backdrops, client-wide un-consume carve-out, Summary how-to+scrollbar), `63bf64c9` (D — gmCG3DView environment backdrop), `e24ec208` (E — text origin/caption escapes/value rects/scrollbars/name prefill), `8c30aa18` (F — Skills page buckets/selection/info box/cost text/arrow states, partial — the four-bucket model itself deferred to the closeout below), `834c2547` (G — real color wheel DoColorSpots/DoGradDisk color computation, left INERT pending the closeout's wiring). Closeout round (this session, dedicated Sonnet implementer): `e1d7d095` (Group 1 — wires Batch G's two STOPPED items: `UiButton`/`UiDatElement` gain a `Tint` property, the flat-fill overlay is replaced by a genuine multiplicative sprite tint, and the DAT-backed color-source seams are threaded through the composition root), `0fed5fdd` (Group 2 — the Skills page four-bucket sorted model Batch F deferred: `ChargenSkillDetail`/`ChargenSkillFormula` thread `SkillBase.MinLevel`/`Description`/`Formula`, `CharacterCreationSkillsPage` groups/sorts/re-buckets, the info box gets its description+formula completion), `bd359d51` (Group 3 — the round review's remaining findings F4-F11/F14/F16: three UiButton corpus sweeps, a narrow `AuthoredInvisible` honor for the chat new-text indicator, `BoundedProcessOutputCapture`'s single-write `AppendLine`, a stale-comment correction, documented (not code-changed) numeric-asymmetry and harmless-set-membership findings, per-page `DatRichText.Compose` caching, and the Summary preview's own render-id pair closing a real cross-page `TextureCache` collision). Docs-only bookkeeping (register/ISSUES/findings-doc corrections for F3/F12/F15) lands in the commit immediately following this ledger update. | Register bookkeeping across the round: AP-216/AP-217 RETIRED (Group 1), AP-213 RETIRED (Group 2), AP-229/AP-230 amended with closeout addenda (F3/F5-F6), the AP section header's inverted "one high" note corrected to "one low" (F12), the AD section header recounted 77->79 (F12); new AP-231 documents the Skills page formula-connector-text approximation. Gates: full-solution Release build green throughout; App suite (live-DAT env) 5358/3, Runtime 1735/0 (unchanged), Core 4797/1, Content 154/0, Launcher.Core 338/0, and the complete solution (12 test projects) 0 failures / 4 skips at the closeout's own final run. | **STILL OWED:** the user's own connected-gate re-run against this closeout's build (nothing in this round replaces the user's own visual/behavioral confirmation of GF-1..GF-16/R2-1..R2-8's fixes); F3's literal ask (a test driving `RetailUiRuntime.Tick(double)` itself rather than its two components separately) was assessed and NOT implemented — `RetailUiRuntimeBindings` requires ~24 nested sub-binding records with no existing lightweight construction path, disproportionate to the value of strengthening an already-correct, already-tested tick-order guarantee (`Finish_EmptyName_RealEventPath_...` already pins the same order via direct calls); AP-231's formula-connector approximation remains unverified against a live retail capture. | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 7a0904e3..a59f03f3 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -270,7 +270,7 @@ tint" halves stay open, judged disproportionate to add alongside this batch's ~10 other fixes), (2) a CLIENT-WIDE `LayoutImporter` fix un-consuming media-bearing dat children on `UiText`/`UiField` (37 distinct (layout, element) pairs across 15 layouts, independently re-derived — -includes MAIN GAME UI and CHAT INPUT, closing the build half of pre-filed +includes MAIN GAME UI and the chat transcript, closing the build half of pre-filed issue #366), (3) the Summary how-to text (`gmCGSummaryPage::SetHowToText`) plus the scrollbar-to-text-scroll linkage Commit 2 left unbound. Fixture + live-DAT tests only (no @@ -632,11 +632,25 @@ ISSUES.md; this doc is the six-page batch. as before. Independently re-derived blast radius: **37 distinct (layout, element) pairs across 15 layouts** (see the commit message for the full enumeration), including MAIN GAME UI (`0x21000005/0x1000059A`) - and CHAT INPUT (`0x2100006F/0x10000011` — closing the BUILD half of + and the chat transcript (`0x2100006F/0x10000011` — closing the BUILD half of pre-filed issue #366's own "fix shape" recommendation, which proposed this EXACT carve-out). Full App suite (5304 tests): zero regressions. **The user's own visual check of chat + the main game UI is still owed** — automated coverage cannot catch a purely visual placement regression. + **Closeout Group 3 (F5/F6, 2026-08-16):** one of this carve-out's + media-bearing children — the chat new-text indicator, `0x1000048C`, + live-DAT-confirmed authored `Invisible=true` on every layout it appears + in (`0x21000005`/`0x21000006`/`0x2100005B`/`0x2100006F`) — was building as + a visible phantom element retail never shows. Fixed with a NARROW honor + scoped to exactly this carve-out (`LayoutImporter.BuildWidget`'s + `UiText or UiField` branch sets a built child's `Visible = false` when its + own `AuthoredInvisible` flag is set), not the general #408 client-wide + honor. Verified in both directions: the invisible chat indicator now + builds hidden, and the eight chargen/main-game-UI gold-frame pieces + (`0x100002DE-E3`/`0x100000E8`/`0x100000EA`) do NOT author `Invisible` and + stay visible — confirmed both by a live-DAT sweep + (`MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible`) + and a fixture regression test. - **GF-14 Summary paperdoll backdrop black — FIXED (Campaign CC gate round 1, Batch D, same fix as GF-7 above — both pages call the identical `gmCG3DView::Update` on their own `gmCG3DView` instance).** **Summary From 956b8d5b6b9b05f33a171c53738e2b66c2157a38 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 16:02:18 +0200 Subject: [PATCH 129/138] docs: Campaign CC gate round 1 re-test 2 findings R3-1..R3-9 Co-Authored-By: Claude Fable 5 --- ...-08-16-campaign-cc-gate-round1-findings.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index a59f03f3..a19b6023 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1,5 +1,45 @@ # Campaign CC connected gate — round 1 findings (2026-08-16) +## RE-TEST 2 (build `1.0.2-cc.k`, post-E/F/G/closeout) — findings R3-1..R3-9 + +Heritage PASSES. Remaining, with retail side-by-side screenshots: + +- **R3-1 Profession: "Coordination" wraps to two lines** on the attribute + label — retail fits one line. Likely the Batch E block-label wrap (or a + wrong/too-large font on the runtime-written `0x100002ED` labels). + Probe the authored font+rect: if the authored font fits, our font + resolution is wrong; if not, retail doesn't word-wrap captions and the + auto-wrap is the bug. +- **R3-2 Skills: "Available Skill Credits" wraps after "Available"**, + hiding behind adjacent graphics — retail is ONE line. Same family as + R3-1 (the Batch E caption-width confinement + wrap). +- **R3-3 Skills info box: the title line ("Item Enchantment (10)") + overlaps the description text** — description must start on the line + below the title. +- **R3-4 SCROLLBAR THUMB MISSING EVERYWHERE (shared mechanism):** skills + list scrollbar and summary scrollbars show track+arrows but no thumb + (retail: the red/gold diamond); the shade slider on the color disc + works but shows no indicator (retail: the small handle across the + disc). One probe: what authors the thumb (a child? state media on the + scrollbar?) and why our UiScrollbar never draws it on these chargen + scrollbars while (per OP-era gates) other scrollbars show thumbs. +- **R3-5 Appearance color wheel render targets:** (a) we tint the RING + art — retail fills the small circle INSIDE the ring (the spot); (b) + beyond-count swatches: retail SHOWS them as blocked/dark circles — we + hide them; (c) the shade indicator on the disc (see R3-4). +- **R3-6 Eyes: retail shows a graphic icon in the disc center** (the + authored eye plug art) and its swatches still show colored rings; + acdream shows an empty ring and dark swatches. Re-derive DoGradDisk's + Eyes branch + the eye swatch rendering. +- **R3-7 Summary scrollbar thumbs** — R3-4 family. +- **R3-8 `[ Name ]` (user re-asserts, third time):** P0x17 and state + text are probed-absent; retail code writes none. UNCHECKED: the + field-widget-specific properties — dump EVERY authored property id + + value on `0x10000402` raw, and check `UIElement_TextInput::OnSetAttribute`'s + full case list for prompt/default-text properties beyond 0x17. If that + is also empty, STOP and request a live retail screenshot of the field + before any further work. + ## ROUND 1 RE-TEST (build `1.0.2-cc.i`, post-Batches B/C/D) — findings R2-1..R2-8 User's second visual pass with retail side-by-side screenshots (heritage From 7d6a7898f6c2c61f7a19e011a143c7300a9e77ce Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:19:57 +0200 Subject: [PATCH 130/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20re-test=202=20=E2=80=94=20R3-1/R3-2=20caption=20wra?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch E's UiButton.DrawBlockLabel/WrapBlockLines auto-wrapped any caption that didn't fit its box width — decomp-wrong. UIElement_Text:: CalcJustification @0x00467260 (shared by GlyphList::Recalculate's horizontal/vertical branches) shows retail's real per-glyph break decision (both the width-triggered wrap AND the explicit-newline break) sits behind ONE gate keyed on the OneLine flag; nothing in the decomp confines a caption's wrap width to a sibling element's rect (Batch E's own ValueBox confinement for the coexisting-value-label shape). Live-DAT evidence: the Coordination attribute-slider label (0x100002ed) authors OneLine=true (should never wrap); the Skills credits button's "Available Skill Credits" caption measures 193px against its own full 231px button width (fits comfortably) — the 113px confined width Batch E fed the wrap decision was never a real retail quantity. Fixed: WrapBlockLines now splits ONLY on the explicit (already-normalized) '\n' — never width-based. Strict superset of the pre-Batch-E single-line draw for every already-correct caption; "Attribute\n Credits" still works. The ValueBox confinement computation stays in OnDraw (still feeds the Center-alignment tx formula) but no longer gates the wrap decision. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/UiButton.cs | 197 ++++++++++++++------ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 64 +++++-- 2 files changed, 189 insertions(+), 72 deletions(-) diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index bf574e4b..9b2dd988 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -166,6 +166,37 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// public Vector4 Tint { get; set; } = Vector4.One; + /// + /// R3-5 (Campaign CC gate round 1 re-test 2): optional resolver + /// returning a PRE-BAKED, already color-key-recolored texture handle + /// (from + /// or equivalent), drawn UNTINTED (1:1, no UV repeat) INSTEAD of the + /// ordinary /ActiveFile sprite. + /// Retail's own gmCGAppearancePage::DoColorSpots @0x0047d850 + /// does NOT multiply-tint the swatch's authored ring+spot sprite (a + /// multiply of a target color against BLACK — the spot template's own + /// placeholder fill, live-DAT-pixel-confirmed — stays black regardless + /// of the tint, and multiplying the ring's own non-black border pixels + /// shifts their hue/brightness, corrupting them). Retail instead calls + /// SurfaceWindow::ReplaceColor: build a fresh composited surface + /// once, blit the spot template onto it, then swap every EXACT-black + /// pixel for the swatch's real color — the ring border (never black) + /// is untouched. This property is that same mechanism's C# seam. + /// itself is left completely unchanged in meaning + /// and is STILL the value callers set to communicate "this button's + /// color is X" (existing callers/tests that only read + /// are unaffected) — this resolver is a SEPARATE + /// decision (deliberately not fed by : a caller may + /// need to distinguish more states — e.g. "beyond count, show the + /// blocked art" versus "no color data yet, show nothing" — than one + /// Vector4 can encode) that only changes what OnDraw does when + /// non-null: consult it for a texture instead of directly multiplying + /// the authored sprite. Null (default, every pre-existing button) + /// preserves the exact prior FaceFileOverride/ActiveFile + + /// multiply-Tint draw. + /// + public Func? ColorKeyFaceResolver { get; set; } + /// Additional left inset for left-aligned labels. public float LabelOffsetX { get; set; } = 3f; @@ -469,6 +500,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful foreach (FaceSegment segment in _faceSegments) DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height)); } + else if (ColorKeyFaceResolver is { } colorKeyResolver) + { + // R3-5: a pre-baked, already-recolored texture (see this + // property's own doc) — drawn UNTINTED and 1:1 (no UV repeat; + // the baked bitmap is uploaded at its own native size, which + // for the chargen swatches equals the button's own authored + // rect, live-DAT-measured). + uint bakedTexture = colorKeyResolver(); + if (bakedTexture != 0) + { + float faceWidth = FaceWidth > 0f ? FaceWidth : Width; + float faceHeight = FaceHeight > 0f ? FaceHeight : Height; + ctx.DrawSprite(bakedTexture, FaceLeft, FaceTop, faceWidth, faceHeight, + 0f, 0f, 1f, 1f, Vector4.One); + } + } else { uint file = FaceFileOverride ?? ActiveFile(_mediaInfo); @@ -497,18 +544,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful float boxWidth = LabelBox?.Width ?? Width; float boxHeight = LabelBox?.Height ?? Height; - // R2-2/R2-3 (Campaign CC gate round 1 Batch E): when this button - // ALSO carries a coexisting ValueLabel (GF-4a's own-caption + - // separate value slot — the Profession attribute/health/stamina/ - // mana credits buttons, the Skills credits button), the caption's - // own drawable region stops before the value's authored rect - // starts. LabelBox and ValueBox are mutually exclusive by - // construction (DatWidgetFactory.BuildButton only ever sets one - // or the other), so this never fights GF-11c's own LabelBox - // confinement above. Live-DAT-measured: "Available Skill Credits" - // is 193px wide in the Skills credits button's 231px-wide box - // whose value box starts at local x=116 — without this, the live - // credits number draws on top of the caption's own tail. + // R2-2/R2-3 (Campaign CC gate round 1 Batch E) + R3-2 correction + // (re-test 2): when this button ALSO carries a coexisting + // ValueLabel (GF-4a's own-caption + separate value slot — the + // Profession attribute/health/stamina/mana credits buttons, the + // Skills credits button), boxWidth still narrows to stop before + // the value's authored rect for the (currently unused, since + // every known ValueBox button is Left-aligned) Center-tx + // formula and the explicit-newline clip rect below — see + // DrawBlockLabel's own doc for why this no longer gates + // WHETHER a single-line caption wraps or clips (R3-2: it never + // did in retail — live-DAT-measured, "Available Skill Credits" + // fits the button's own full 231px width with room to spare). if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX) boxWidth = MathF.Min(boxWidth, valueBoxX - boxX); @@ -543,20 +590,58 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } /// - /// R2-2 (Campaign CC gate round 1 Batch E): retail's UIElement_Button - /// IS a UIElement_Text (struct UIElement_Button : UIElement_Text, - /// acclient.h) — these captions author OneLine=false - /// (live-DAT-probe-confirmed on 0x100003e2-e5/0x100003f9), so a caption - /// that carries an authored newline (already normalized to a real - /// '\n' by 's shared - /// ResolveAuthoredString) OR simply doesn't fit - /// lays out as multiple stacked lines, using - /// the SAME word-wrap any other Type-12 - /// text box uses. A single line that already fits draws with byte- - /// identical geometry to the pre-fix unconditional one-line math (same - /// centered-block Y, same tx formula) — this is a strict superset, not a - /// behavior change, for every button whose caption was already short - /// enough to fit on one line. + /// R2-2 (Campaign CC gate round 1 Batch E) + R3-1/R3-2 (re-test 2 + /// correction): retail's UIElement_Button IS a + /// UIElement_Text (struct UIElement_Button : UIElement_Text, + /// acclient.h) — a caption that carries an authored newline + /// (already normalized to a real '\n' by + /// 's shared + /// ResolveAuthoredString) lays out as multiple stacked lines. A + /// single line that already fits draws with byte-identical geometry to + /// the pre-Batch-E unconditional one-line math (same centered-block Y, + /// same tx formula). + /// + /// Batch E ALSO auto-wrapped a paragraph that doesn't fit + /// via — re- + /// derived at re-test 2 (R3-1 "Coordination"/R3-2 "Available Skill + /// Credits") as the wrong shape and REMOVED: live-DAT-probed, the + /// Coordination slider label (0x100002ed) authors OneLine= + /// true (dat property 0x20) and the Skills credits button + /// (0x100003f9) authors OneLine=false yet BOTH render one + /// line in retail. Tracing GlyphList::Recalculate + /// @0x00473800's per-glyph loop: the ENTIRE width-triggered break + /// decision (and, separately, the explicit-newline break) sits behind + /// one gate, if (arg3 == 0) where arg3 is the SAME + /// OneLine boolean passed in from + /// UIElement_Text::ResizeToPaper/InqSize — i.e. a + /// caption's width is measured against its own FULL element rect (minus + /// margins), never against a sibling/child element's geometry; nothing + /// in the decomp confines a caption's wrap width to stop before another + /// element's rect. The 193px "Available Skill Credits" caption fits the + /// button's own full 231px width (live-DAT-measured) with room to + /// spare — it never needed to wrap at all. So: split ONLY on the + /// explicit \n (never invoke ) — a + /// strict superset of the pre-Batch-E single-line draw for every + /// caption that was already correct, and the exact shape "Attribute\n + /// Credits" (an authored break) still needs. + /// + /// + /// R3-2 deliberately does NOT clip a single (unwrapped) line to + /// either, even when the caller narrowed it + /// via a coexisting — clipping would cut the + /// caption's own tail off mid-word, which contradicts "retail is ONE + /// line" just as much as wrapping does (a viewer would call that + /// truncated, not "one line"). The 193px-in-231px Skills-credits + /// geometry means the caption's rendered span (x≈3 to x≈196) does + /// overlap the value's own rect (x=116 to x=150, live-DAT-measured) in + /// principle — Batch E's own diagnosis of the ORIGINAL R2-2/R2-3 + /// "24dits"/"Credit0Credits" reports. That overlap is NOT re-solved + /// here: this fix only removes the false wrap this specific finding + /// (R3-2) reported, and inventing an unevidenced clip boundary to + /// pre-empt a DIFFERENT, not-currently-reported symptom would be + /// exactly the guessing this project's workflow forbids. Flagged in + /// the findings doc for the user's own re-check once the wrap is gone. + /// /// private void DrawBlockLabel( UiRenderContext ctx, @@ -574,13 +659,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful text, font.MeasureWidth, font.LineHeight, boxX, boxY, boxWidth, boxHeight, align, leftOffset); - // A multi-line result clips to its own box — the button's normal - // draw has no ambient clip, and an oversized wrapped caption (e.g. - // the Skills credits button's own tight 28px height) should be cut - // off at the box edge rather than spill into whatever sits below the - // button, matching every other clipped Type-12 text box in this - // codebase (UiText.DrawText's own PushClip). Single-line captions — - // the overwhelming majority — never pay this cost. + // A multi-line result (an authored '\n') clips to its own box — the + // button's normal draw has no ambient clip, and an oversized + // wrapped caption (e.g. the Skills credits button's own tight 28px + // height) should be cut off at the box edge rather than spill into + // whatever sits below the button, matching every other clipped + // Type-12 text box in this codebase (UiText.DrawText's own + // PushClip). Single-line captions — the overwhelming majority, + // and (post-R3-2) EVERY caption with no authored newline — never + // pay this cost; see this method's own doc for why a single line + // is deliberately left unclipped even when boxWidth was narrowed. bool clip = lines.Count > 1; if (clip) ctx.PushClip(boxX, boxY, boxWidth, boxHeight); @@ -597,14 +685,24 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } /// - /// Pure geometry half of — normalized - /// newline split + word-wrap () to - /// , then block-centered vertically within + /// Pure geometry half of — split ONLY on an + /// authored explicit '\n', then block-centered vertically within /// . Pulled out as a static/pure method - /// (same shape as ) so the wrap/ - /// confinement math is unit-testable without a font atlas or draw - /// context — takes the place of + /// (same shape as ) so the geometry + /// is unit-testable without a font atlas or draw context — + /// takes the place of /// . + /// + /// R3-1/R3-2 (re-test 2): deliberately does NOT width-wrap a paragraph + /// that overflows — see + /// 's own doc for the decomp citation + /// (GlyphList::Recalculate's width-triggered break sits behind + /// the SAME OneLine gate as the explicit-newline break, and + /// retail never confines a caption's wrap width to a sibling element's + /// rect). A paragraph that overflows still draws as one line, unclipped + /// by width — matching every plain (no authored \n) button + /// caption in retail, which is never observed to wrap. + /// /// internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines( string text, @@ -617,26 +715,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful LabelAlignment align, float leftOffset) { - float availableWidth = MathF.Max( - 1f, - boxWidth - (align == LabelAlignment.Left ? leftOffset : 0f)); + string[] lines = text.Split('\n'); - var lines = new List(); - foreach (string paragraph in text.Split('\n')) - { - if (measureWidth(paragraph) <= availableWidth) - { - lines.Add(paragraph); - continue; - } - lines.AddRange(UiText.WrapWords(paragraph, measureWidth, availableWidth)); - } - - float totalHeight = lines.Count * lineHeight; + float totalHeight = lines.Length * lineHeight; float startY = boxY + (boxHeight - totalHeight) * 0.5f; - var result = new List<(string, float, float)>(lines.Count); - for (int i = 0; i < lines.Count; i++) + var result = new List<(string, float, float)>(lines.Length); + for (int i = 0; i < lines.Length; i++) { string line = lines[i]; float tx = align == LabelAlignment.Left diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 7ad930be..faeabee7 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -461,33 +461,65 @@ public class UiButtonTests } /// - /// R2-3: a single-paragraph caption with NO authored newline still - /// word-wraps when it doesn't fit the available width — the exact - /// live-DAT shape of the Skills credits button's own "Available Skill - /// Credits" caption (measured 193px in a 231px-wide button whose value - /// box starts at local x=116, i.e. only ~113px of caption width is - /// actually available once R2-2/R2-3's confinement applies). + /// R3-2 (re-test 2 correction, supersedes the retired Batch E + /// "WordWrapsToFitAvailableWidth" expectation): a single-paragraph + /// caption with NO authored newline stays ONE line even when it + /// overflows the available width — the exact live-DAT shape of the + /// Skills credits button's own "Available Skill Credits" caption + /// (measured 193px, live-DAT-probed against the button's own FULL + /// 231px width, which it fits comfortably — the 113px figure in the + /// old test was the WRONG width in the first place, since retail never + /// confines a caption's wrap width to a sibling value element's rect; + /// see 's own doc for the + /// GlyphList::Recalculate citation). Even forced into an artificially + /// narrow box (as here), the caption must NOT wrap — retail's + /// UIElement_Button captions only ever split on an authored \n. /// [Fact] - public void WrapBlockLines_LongSingleParagraph_WordWrapsToFitAvailableWidth() + public void WrapBlockLines_LongSingleParagraph_NeverWordWraps() { var lines = UiButton.WrapBlockLines( "Available Skill Credits", BitmapMeasure, lineHeight: 24f, boxX: 0f, boxY: 0f, boxWidth: 113f, boxHeight: 28f, UiButton.LabelAlignment.Left, leftOffset: 3f); - Assert.True(lines.Count > 1, "a 193px caption must wrap within a 110px available width"); - foreach (var line in lines) - Assert.True(BitmapMeasure(line.Text) <= 110f, $"line '{line.Text}' overflowed"); + Assert.Single(lines); + Assert.Equal("Available Skill Credits", lines[0].Text); } /// - /// R2-2/R2-3 confinement itself, exercised through OnDraw's own gate: - /// a button with BOTH Label and a coexisting ValueBox shrinks the - /// caption's OWN drawable width to stop before the value box starts — - /// this is what the two live-DAT overlap reports (R2-2 "24dits", R2-3 - /// "Credit0Credits") trace to: the caption used to draw across the - /// WHOLE button width regardless of where the value sat. + /// R3-1 (re-test 2): the Profession/chargen attribute slider name label + /// (element 0x100002ed, e.g. "Coordination") authors OneLine= + /// true and a 115px-wide box — live-DAT-measured against the real + /// dat font, the caption itself is 113px wide, just 1px narrower than + /// the raw box but 1px WIDER than the box minus the class's own default + /// 3px LabelOffsetX (112px) — exactly the boundary the retired + /// Batch E width-check would have tripped on, wrapping a single WORD + /// (no space to break at) into a garbled two-line split. Pins that this + /// no longer happens for any box/text combination, narrow or not. + /// + [Fact] + public void WrapBlockLines_SingleWordNarrowerThanBoxButWiderThanOffsetAdjustedWidth_StaysOneLine() + { + var lines = UiButton.WrapBlockLines( + "Coordination", BitmapMeasure, lineHeight: 24f, + boxX: 0f, boxY: 0f, boxWidth: 115f, boxHeight: 24f, + UiButton.LabelAlignment.Left, leftOffset: 3f); + + Assert.Single(lines); + Assert.Equal("Coordination", lines[0].Text); + } + + /// + /// The ValueBox-vs-Label confinement math itself, exercised through + /// OnDraw's own computation: a button with BOTH Label and a coexisting + /// ValueBox still shrinks the caption's OWN boxWidth to stop before the + /// value box starts. As of R3-2 (re-test 2) this confined width no + /// longer changes whether or how the caption draws — a single + /// (unwrapped) line is never clipped to it (see + /// 's own doc) — so this test only + /// pins that the computation itself is unchanged, not that it gates + /// any rendering decision. /// [Fact] public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox() From 7f6e93033f6812c5f4fae0ed20d65e20d11dae0f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:20:08 +0200 Subject: [PATCH 131/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20re-test=202=20=E2=80=94=20R3-3=20skills=20info-box?= =?UTF-8?q?=20VerticalJustify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The info-box title (0x100003fb, Y=435 H=100) and description (0x100003fc, Y=460 H=100) panes' own authored boxes overlap by 75px, live-DAT-measured — retail relies on vertical justification, not disjoint rects, to keep them visually separate. Neither pane authors dat property 0x15, so both fall to this port's shared unauthored-VJustify default (currently Center). Byte-traced retail's real ctor default (UIElement_Text::UIElement_Text @0x004685ff, m_eVerticalJustification = 4) against UIElement_Text:: CalcJustification @0x00467260's actual enum semantics (1=Center, 3-or-5= the far edge/Bottom, anything else INCLUDING the ctor's own default of 4 = the near edge/Top): the correct unauthored default is Top, not Center — a genuine client-wide enum-mapping bug in this port. Under Top both panes render near their own box's top edge (25px apart, no collision); under Center both cluster toward the middle of their overlapping boxes. Scoped fix: CharacterCreationSkillsPage force-sets VerticalJustify=Top on both panes directly, rather than fixing the shared mapping/default — that bug is client-wide and could regress already-shipped FROZEN surfaces (vitals, chat, main game UI, Options) that may rely on the current Center default. The shared fix is filed as ISSUES #410 / register AD-104 for its own dedicated investigation + regression sweep. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 81 +++++++++++++++++++ .../retail-divergence-register.md | 3 +- .../UI/Layout/CharacterCreationSkillsPage.cs | 36 +++++++++ .../CharacterCreationUiControllerTests.cs | 24 ++++++ 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b4848f4a..9a1b9814 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,87 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #410 — Client-wide VJustify (vertical text justification) enum mapping + unauthored default are wrong (retail default is Top, not Center) + +**Status:** OPEN +**Severity:** MEDIUM (silently mispositions every DAT-imported `UiText` that +relies on the unauthored default, or that authors a raw vertical- +justification value other than 1 — currently invisible unless two +elements' boxes are close/overlapping the way the Skills info-box panes +are, but could affect vertical alignment anywhere client-wide) + +Found during Campaign CC gate round 1 re-test 2's R3-3 investigation +(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`). The +Skills page's info-box title (`0x100003fb`) and description (`0x100003fc`) +panes author NO dat property `0x15` (vertical justification) — live-DAT- +probe-confirmed absent on both — so both fall to whatever this port's +unauthored default resolves to, currently `VJustify.Center` +(`ElementReader.cs`'s `VJustify` field default and +`ElementReader.cs`/`DatWidgetFactory.cs`'s import-time mapping switches). + +Byte-traced against retail: + +- `UIElement_Text::UIElement_Text` (ctor) `@0x004685ff`: unconditionally + sets `this->m_eVerticalJustification = 4` (and + `m_eHorizontalJustification = 2` at `@0x004685f5`) BEFORE any dat + property is applied — i.e. retail's real unauthored default is the raw + value **4**, not whatever a "sensible default" might suggest. +- `UIElement_Text::CalcJustification` `@0x00467260`: the ACTUAL enum + semantics, shared by both the horizontal and vertical branches via one + `ecx_5` comparison — `ecx_5 == 1` → **Center**; `ecx_5 == 3 || ecx_5 == 5` + → the FAR edge (**Right** for horizontal, **Bottom** for vertical); any + OTHER value (0, 2, 4, ...) → `edi = 0`, the NEAR edge (**Left** for + horizontal, **Top** for vertical). + +Cross-referencing: the ctor's own vertical default of 4 resolves via this +real semantic table to **Top**, not Center. This port's +`ElementReader.cs:507`'s import-time switch (`2u=>Top, 4u=>Bottom, +_=>Center`) and `DatWidgetFactory.cs:704`'s build-time switch are BOTH +wrong relative to the real table — only raw value `2` (coincidentally +falling into the correct "near edge" bucket) and `1` (Center, matching the +`_=>Center` catch-all by coincidence) currently resolve correctly; `0`, +`3`, `4`, and `5` all resolve to the wrong bucket. The `ElementInfo.VJustify` +field default (`VJustify.Center`) is ALSO wrong — it should be `Top` to +match the ctor's real resolved value. + +**Why this is filed instead of fixed here:** the blast radius is +client-wide — every DAT-imported `UiText` that reaches the +`Centered`/`RightAligned`/`OneLine` static paths or the multi-line +honored-justification path (`_honorDatVerticalJustification`, set +unconditionally by `ConfigureDatState` for every DAT-imported text +element) is affected, including already-shipped, visually-verified, +FROZEN surfaces (vitals numbers, chat, main game UI, Options panel) that +may be relying on the CURRENT (wrong) Center default for their existing +correct-looking vertical alignment. Flipping the shared default/mapping +without a full client-wide regression sweep risks reintroducing +regressions in surfaces this session has no budget to re-verify. R3-3's +own fix (`CharacterCreationSkillsPage`'s constructor) scopes the +correction to ONLY the two Skills info-box panes via an explicit +`VerticalJustify = VJustify.Top` post-construction assignment — a +targeted, decomp-grounded correction that does not touch the shared +mapping. + +**Fix direction when this issue is picked up:** (1) correct +`ElementReader.cs`'s import-time switch AND `DatWidgetFactory.cs`'s +build-time switch to the real table above (`1=>Center, 3 or 5=>Bottom, +else=>Top`) for BOTH horizontal and vertical justification (audit the +horizontal switch too — it currently special-cases `0u or 2u=>Left` +instead of "everything except 1/3/5"; likely benign today since 2 is the +only unauthored horizontal default in practice, but should be corrected +for the same reason); (2) flip `ElementInfo.VJustify`'s field default to +`Top`; (3) fix `ElementReader.cs:435`'s `Merge` sentinel +(`derived.VJustify != VJustify.Center ? derived : base_`) to use the NEW +default (`Top`) as the "unset" sentinel instead, or restructure to a +nullable/explicit-override tracking shape so the merge doesn't rely on a +magic default value at all; (4) a full client-wide live-DAT sweep of every +Type-12/Button element that authors OR omits property `0x15`/`0x14`, +cross-checked against a fresh full visual pass of chat, main game UI, +Options, and every chargen page (this port's own `CharacterCreationSkillsPage` +override from R3-3 should be REMOVED once the shared default is corrected, +since it would then be redundant); (5) the exact same audit for the +horizontal `HJustify` mapping while in this code, since it shares the +`CalcJustification` function and the same class of latent bug. + ## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1) **Status:** OPEN diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9c9ce5e9..84980f5b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 79 active rows (F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 80 active rows (AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -192,6 +192,7 @@ readiness/requeue adaptation. See | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. **Campaign CC CC4 review-fix round R1 (2026-08-15): `FixedCanvasSize` now has a single arbiter.** Character-creation can be simultaneously active on top of character-management (both author the same 800x600 canvas), so a raw property write from either controller was a last-writer-wins race with no owner — chargen's own Close() nulled the canvas out from under a still-active character-management screen underneath it. `UiRoot.DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` now own every production write: each screen declares on its activation edge and revokes on close/deactivate/dispose; the effective size is the current declaration set's value (asserted equal across every concurrent declarer — a future mismatched screen throws instead of silently winning), and it nulls only once EVERY declarer has revoked. The raw `FixedCanvasSize` setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `DeclareFixedCanvas`, `RevokeFixedCanvas`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` and `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (both declare/revoke through the arbiter on activate/close/deactivate/dispose) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) DECLARES via `UiRoot.DeclareFixedCanvas` while active and REVOKES on close — per-screen opt-in through the arbiter, not automatic and not a raw write. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `CharacterScreensFixedCanvasArbiterTests` (the two-controller arbiter gate); `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | 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-104 | **Filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 (Skills info-box title/description overlap).** `CharacterCreationSkillsPage` force-sets `VerticalJustify = VJustify.Top` on the info-box title (`0x100003fb`) and description (`0x100003fc`) panes post-construction, compensating for a client-wide bug: neither element authors dat property `0x15`, and this port's shared unauthored-VJustify default (`ElementInfo.VJustify` field default `Center`, plus `ElementReader.cs`/`DatWidgetFactory.cs`'s import/build-time enum-mapping switches) resolves an absent `0x15` to Center — but retail's REAL ctor default (`UIElement_Text::UIElement_Text @0x004685ff`, `m_eVerticalJustification = 4`) resolves via `UIElement_Text::CalcJustification @0x00467260`'s actual enum table (`1=>Center, 3 or 5=>Bottom(far edge), else=>Top(near edge)`) to Top, not Center. The two panes' own AUTHORED boxes overlap by 75px (title Y=435 h=100, description Y=460 h=100, live-DAT-measured) — under the CORRECT Top default both render near their own box's top edge (25px apart) and no longer collide; under the port's current (wrong) Center default both cluster near the middle of their overlapping boxes and visually collide. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (constructor, post-`_infoTitle`/`_infoText` resolution) | The shared mapping bug (`ElementReader.cs:507`'s switch, `DatWidgetFactory.cs:704`'s switch, and `ElementInfo.VJustify`'s field default) is CLIENT-WIDE and affects every DAT-imported `UiText` reaching the `Centered`/`RightAligned`/`OneLine` static paths or the multi-line honored-justification path — including already-shipped, visually-verified, FROZEN surfaces (vitals numbers, chat, main game UI, Options panel) that may rely on the CURRENT Center default for their existing correct-looking alignment. A page-scoped override for exactly the two elements proven broken avoids a client-wide regression sweep this session has no budget for; the shared fix is filed as ISSUES.md #410 for its own dedicated investigation. | If ISSUES #410's shared fix ever lands, this page's override becomes redundant (harmless but should be removed in the same commit, since the corrected shared default would already resolve to Top). Until then, any OTHER DAT-imported `UiText` with an unauthored `0x15` that happens to sit close to a sibling text element (the same "two 100px-tall overlapping boxes" shape) can exhibit the same visual-collision symptom, undiscovered until its own gate round. | `UIElement_Text::UIElement_Text @0x004685ff` (ctor default = 4); `UIElement_Text::CalcJustification @0x00467260` (real enum semantics); ISSUES.md #410 | | 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-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-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) | diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index 8531ddac..b8264fcb 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -305,6 +305,42 @@ internal sealed class CharacterCreationSkillsPage : IDisposable _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; + + // R3-3 (Campaign CC gate round 1 re-test 2): the title + // (0x100003fb, Y=435, Height=100) and description (0x100003fc, + // Y=460, Height=100) panes' own AUTHORED boxes overlap by 75px + // (live-DAT-measured) — retail relies on vertical JUSTIFICATION, + // not disjoint rects, to keep the two visually separate. Neither + // element authors dat property 0x15 (live-DAT-probe-confirmed + // absent on both), so both fall to whatever the unauthored default + // resolves to. Byte-traced against retail's own + // UIElement_Text::UIElement_Text ctor @0x004685ff + // (this->m_eVerticalJustification = 4) cross-referenced with + // UIElement_Text::CalcJustification @0x00467260 (the ACTUAL + // enum semantics: ecx_5==1 -> Center, ecx_5==3||5 -> the FAR edge + // (Bottom), any other value including the ctor's own default of 4 + // -> edi=0, the NEAR edge, i.e. Top): the correct unauthored + // default is TOP, not Center. This port's shared + // ElementReader/DatWidgetFactory VJustify mapping and field + // default both currently resolve an absent 0x15 to Center — a + // client-wide mismatch with real retail semantics that is NOT + // fixed here (filed as ISSUES.md #410; the blast radius spans + // every already-shipped DAT-imported UiText that relies on the + // CURRENT Center default, so a global remap needs its own + // dedicated investigation + regression sweep, not a bundled + // fix inside this page). Scoped correction: force these two + // specific panes to the value retail's ctor actually resolves + // to. Under Top justification the title (OneLine, ~1 line) sits + // near its box's own top (global Y~435) and the description + // (multi-line, honoring the SAME justification via + // ConfigureDatState's _honorDatVerticalJustification) starts near + // ITS box's own top (global Y~460) — the two boxes' TOP edges are + // 25px apart, so short/typical content no longer collides even + // though the boxes' full 100px extents still overlap on paper. + if (_infoTitle is { } infoTitle) + infoTitle.VerticalJustify = VJustify.Top; + if (_infoText is { } infoText) + infoText.VerticalJustify = VJustify.Top; } internal void Refresh( diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index f3607692..72574340 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -433,6 +433,30 @@ public sealed class CharacterCreationUiControllerTests JoinedText(environment.SkillInfoText())); } + /// + /// R3-3 (Campaign CC gate round 1 re-test 2): the info-box title and + /// description panes' own AUTHORED boxes overlap by 75px (live-DAT- + /// measured, see 's + /// constructor comment for the full geometry + decomp citation) — + /// retail avoids the visual collision via vertical justification, not + /// disjoint rects. Pins that the page forces both panes to Top so the + /// title sits near ITS box's own top and the description sits near + /// its own, instead of both clustering toward the middle of their + /// overlapping boxes under the shared (currently wrong, ISSUES #410) + /// Center default. + /// + [Fact] + public void SkillsPage_InfoBoxPanes_ForceTopVerticalJustify_ToAvoidTitleDescriptionOverlap() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + Assert.Equal(VJustify.Top, environment.SkillInfoTitle().VerticalJustify); + Assert.Equal(VJustify.Top, environment.SkillInfoText().VerticalJustify); + } + /// R2-4a: retail re-selects the row after an arrow click too /// (ListenToElementMessage @0x004814c0's own /// SetSelectedItem(...,1) call following From c9313edc63d7c547bd2dad08e4483bd75cefc2d3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:20:16 +0200 Subject: [PATCH 132/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20re-test=202=20=E2=80=94=20R3-4/R3-7=20scrollbar=20t?= =?UTF-8?q?humb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail authors TWO distinct UIElement_Scrollbar thumb shapes. DatWidgetFactory.BuildScrollbar's existing vertical-thumb detection was built against chat's own scrollbar (0x10000012) — a 3-slice composite where the thumb child carries no media of its own and three Type-3 grandchildren supply the top-cap/middle/bottom-cap sprites. The chargen Skills listbox scrollbar (0x100003f8), Summary's OVERVIEW listbox scrollbar (0x10000401), the Summary how-to box's scrollbar (0x100002e7), and the shade slider (0x10000321) all instead author a SIMPLE single-sprite thumb: the same structural child (Type 1, id 1, not the inc/dec button) carries its OWN direct media and has ZERO children — the 3-slice-only search found nothing for this shape, so every Thumb*Sprite stayed 0 regardless of overflow. Fixed by falling back to the thumb's own DefaultImage when the slice search finds nothing — additive; a thumb WITH real slice children (chat) is unaffected. This one fix covers R3-4's three listbox thumbs, R3-7, and — as a natural consequence of the same structural shape — the shade-slider indicator half of R3-5(c); no separate fix was needed there. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 25 +++++ .../UI/Layout/DatWidgetFactoryTests.cs | 103 ++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index b726df79..86b037e4 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -315,6 +315,31 @@ public static class DatWidgetFactory if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]); if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]); if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]); + + // R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors + // TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) — + // chat's own scrollbar (0x10000012) is the 3-slice composite the + // block above was built against (the thumb CHILD carries no media + // of its own; three Type-3 grandchildren supply the top-cap/ + // middle/bottom-cap sprites) — but the chargen Skills listbox + // (0x100003f8), Summary's OVERVIEW listbox (0x10000401), and the + // Summary how-to box (0x100002e7 under 0x10000404) all author a + // SIMPLE single-sprite thumb instead: the SAME structural child + // (Type 1, id 1, not the inc/dec button) carries its OWN direct + // Normal/Normal_rollover/Normal_pressed media and has ZERO + // children (live-DAT-probe-confirmed against all three — no + // slice grandchildren to find, so `slices` above is always + // empty for this shape and every Thumb*Sprite stayed 0, + // matching the reported "track+arrows render, no thumb" + // symptom). already falls back + // to a single tiled `ThumbSprite` blit when the cap sprites are + // unset (`ThumbTopSprite != 0 && ThumbBotSprite != 0` gate), so + // the only missing piece is feeding it the thumb's OWN media + // when it has no slice children — additive: a thumb WITH real + // slice children (chat) is unaffected since `slices.Length == 0` + // is false for that shape. + if (slices.Length == 0) + bar.ThumbSprite = DefaultImage(thumb); } return bar; diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index e9066456..f6d78b60 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -637,6 +637,109 @@ public class DatWidgetFactoryTests Assert.Equal(0u, bar.ThumbSprite); } + /// + /// R3-4/R3-7 (Campaign CC gate round 1 re-test 2): a VERTICAL scrollbar + /// whose thumb child carries its OWN direct media (Normal/ + /// Normal_rollover/Normal_pressed) and has NO slice children — the + /// exact live-DAT shape of the chargen Skills listbox scrollbar + /// (0x100003f8), Summary's OVERVIEW listbox scrollbar (0x10000401), + /// and the how-to box's own scrollbar (0x100002e7). Before this fix + /// ThumbSprite stayed 0 (the 3-slice-children search this factory was + /// originally built against — chat's scrollbar shape, see + /// + /// — found nothing to select), so the track and arrows rendered but + /// the thumb never did, regardless of overflow. + /// + [Fact] + public void Type11_VerticalScrollbar_SingleSpriteThumbWithNoSliceChildren_SetsThumbSprite() + { + const uint Thumb = 0x06005A11u; + const uint DecrementId = 0x10000071u; + const uint IncrementId = 0x10000072u; + var decrement = new ElementInfo { Id = DecrementId, Type = 1u, Y = 83f, Width = 37f, Height = 17f }; + decrement.StateMedia["Normal"] = (0x06004C69u, 1); + var increment = new ElementInfo { Id = IncrementId, Type = 1u, Y = 0f, Width = 37f, Height = 17f }; + increment.StateMedia["Normal"] = (0x06004C6Cu, 1); + var thumb = new ElementInfo { Id = 1u, Type = 1u, Width = 37f, Height = 39f }; + thumb.StateMedia["Normal"] = (Thumb, 1); + thumb.StateMedia["Normal_rollover"] = (0x06005A12u, 1); + thumb.StateMedia["Normal_pressed"] = (0x06005A13u, 1); + thumb.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Thumb, 1) }; + + var info = new ElementInfo + { + Type = 11u, + Width = 37f, + Height = 307f, + Children = [decrement, increment, thumb], + }; + var state = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + state.Properties.Values[0x77u] = new UiPropertyValue + { Kind = UiPropertyKind.Enum, UnsignedValue = IncrementId }; + state.Properties.Values[0x78u] = new UiPropertyValue + { Kind = UiPropertyKind.Enum, UnsignedValue = DecrementId }; + info.States[UiStateInfo.DirectStateId] = state; + + var bar = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.False(bar.Horizontal); + Assert.Equal(Thumb, bar.ThumbSprite); + Assert.Equal(0u, bar.ThumbTopSprite); + Assert.Equal(0u, bar.ThumbBotSprite); + } + + /// + /// Negative companion: a VERTICAL thumb WITH real 3-slice children + /// (chat's own shape) is unaffected by the R3-4/R3-7 fallback — the + /// `slices.Length == 0` gate never triggers, so ThumbSprite/Top/Bot + /// come from the slice children exactly as before. + /// + [Fact] + public void Type11_VerticalScrollbar_ThumbWithSliceChildren_StillUsesSliceMedia() + { + const uint Top = 0x06004C60u; + const uint Mid = 0x06004C63u; + const uint Bot = 0x06004C66u; + var decrement = new ElementInfo { Id = 0x10000071u, Type = 1u, Y = 0f, Width = 16f, Height = 16f }; + decrement.StateMedia["Normal"] = (0x06004C69u, 1); + var increment = new ElementInfo { Id = 0x10000072u, Type = 1u, Y = 32f, Width = 16f, Height = 16f }; + increment.StateMedia["Normal"] = (0x06004C6Cu, 1); + var thumb = new ElementInfo { Id = 1u, Type = 1u, Width = 16f, Height = 16f }; + var topCap = new ElementInfo { Id = 0x10000364u, Type = 3u, Y = 0f, Width = 16f, Height = 3f }; + topCap.StateMedia["Normal"] = (Top, 1); + topCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Top, 1) }; + var mid = new ElementInfo { Id = 0x10000365u, Type = 3u, Y = 3f, Width = 16f, Height = 10f }; + mid.StateMedia["Normal"] = (Mid, 1); + mid.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Mid, 1) }; + var botCap = new ElementInfo { Id = 0x10000366u, Type = 3u, Y = 13f, Width = 16f, Height = 3f }; + botCap.StateMedia["Normal"] = (Bot, 1); + botCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Bot, 1) }; + thumb.Children.Add(topCap); + thumb.Children.Add(mid); + thumb.Children.Add(botCap); + + var info = new ElementInfo + { + Type = 11u, + Width = 16f, + Height = 73f, + Children = [decrement, increment, thumb], + }; + var state = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + state.Properties.Values[0x77u] = new UiPropertyValue + { Kind = UiPropertyKind.Enum, UnsignedValue = 0x10000072u }; + state.Properties.Values[0x78u] = new UiPropertyValue + { Kind = UiPropertyKind.Enum, UnsignedValue = 0x10000071u }; + info.States[UiStateInfo.DirectStateId] = state; + + var bar = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.False(bar.Horizontal); + Assert.Equal(Top, bar.ThumbTopSprite); + Assert.Equal(Mid, bar.ThumbSprite); + Assert.Equal(Bot, bar.ThumbBotSprite); + } + [Fact] public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider() { From 2886f79f37719f4ffd69292f3d4ad13f9043f31e Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:20:31 +0200 Subject: [PATCH 133/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20re-test=202=20=E2=80=94=20R3-5/R3-6=20color-key=20s?= =?UTF-8?q?watch/gradient=20textures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-derived gmCGAppearancePage::DoColorSpots @0x0047d850 and DoGradDisk @0x0047da90: retail does NOT multiply-tint the swatch/grad-circle's authored sprite. It builds a fresh composited surface once (CreateLocalSurface + Blit), then calls SurfaceWindow::ReplaceColor against old-color RGBAColor(0,0,0,1) (opaque black — the spot template's own placeholder fill, live-DAT-pixel-confirmed: the 37x44 "spot" resource has a genuine solid-black CENTER and a genuine non-black RING) — swapping every exact opaque-black pixel for the swatch's real color while leaving the ring untouched. A multiply-tint (Batch G's mechanism) is architecturally wrong: black multiplied by any color stays black (never recolors the center), and multiplying the ring's own non-black pixels corrupts them — exactly the reported "we tint the ring" symptom. Beyond-count swatches (R3-5b) use a COMPLETELY DIFFERENT authored resource (enum 0x1000000f, "blank" — pixel-confirmed almost no black at all, i.e. genuinely different art) shown untinted, and retail's own pColor->SetVisible(1) is unconditional for all 9 swatches (never hidden). For Eyes (R3-6), DoGradDisk's Eyes branch blits the "grad plug" icon (enum 0x10000010) untinted, and SetSelection's own Eyes/non-Eyes tail never hides m_pGradCircle at all — a correction to this port's prior "_gradCircle.Visible = !isEyes" line. Ported via a new ChargenColorSpotComposer (CPU-side decode-once + per-color bake-and-cache-once through the existing TextureCache.UploadRgba8 seam — the same shape IconComposer.GetSpellComponentIcon already established for item icons, just matching black instead of white) and a new opt-in UiButton.ColorKeyFaceResolver / reuse of the existing UiDatElement.RuntimeImageTexture seam — both additive. Tint keeps its existing meaning for every reader/test; the grad circle's Tint stays a genuine multiply for the non-Eyes case (retail's own Blit_Multiply there). Wired as a fourth late-bound composition seam (SwatchTextureSource), same pattern/site as the existing three color-computation seams. Code-complete, unit/live-DAT-tested (including pixel-level proof of the spot/blank templates' actual content); the user's connected visual gate is owed — no client launches this batch. Co-Authored-By: Claude Fable 5 --- .../LivePresentationComposition.cs | 8 + .../Layout/CharacterCreationAppearancePage.cs | 101 ++++++++- .../Layout/CharacterCreationUiController.cs | 10 + .../UI/Layout/ChargenColorSpotComposer.cs | 206 ++++++++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 14 ++ ...rCreationAppearancePageSwatchColorTests.cs | 37 +++- .../Layout/ChargenColorSpotComposerTests.cs | 101 +++++++++ 7 files changed, 455 insertions(+), 22 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 6029f3f5..60246361 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1080,6 +1080,14 @@ internal sealed class LivePresentationCompositionPhase interaction.RetainedUi.Runtime.ChargenPalSetSource = chargenCatalog; interaction.RetainedUi.Runtime.ChargenClothingTableSource = chargenCatalog; interaction.RetainedUi.Runtime.ChargenPaletteColorSource = chargenCatalog; + // R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth + // seam — needs a TextureCache (foundation.TextureCache, already + // acquired above for the preview renderer), so it is its own + // composer rather than folded into chargenCatalog (a pure + // Content-layer DAT reader with no GL/backend dependency). + var chargenSwatchTextures = new AcDream.App.UI.Layout.ChargenColorSpotComposer( + content.Dats, foundation.TextureCache); + interaction.RetainedUi.Runtime.ChargenSwatchTextureSource = chargenSwatchTextures; bindings.AdoptRelease( "chargen preview control", () => diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index cadde2ae..e98e81e2 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -236,6 +236,28 @@ internal sealed class CharacterCreationAppearancePage : IDisposable internal IChargenClothingTableSource? ClothingTableSource { get; set; } internal IChargenPaletteColorSource? PaletteColorSource { get; set; } + /// + /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) late-bound seam, same + /// pattern as the three above: null (the default) leaves every swatch/ + /// the gradient disc falling back to their ordinary FaceFileOverride/ + /// ActiveFile draw — live-DAT-confirmed, each swatch and the grad + /// circle DOES author its own DirectState sprite (the RAW, + /// un-recolored spot/gradDisk template respectively — the exact same + /// RenderSurface DIDs ChargenColorSpotComposer resolves by + /// enum), so the unwired fallback shows that authored art untinted + /// (Tint stays Vector4.One when no color data exists), not literally + /// nothing. Wired by the composition root to a + /// (same site as + /// et al) once a TextureCache exists. + /// See 's own doc for WHY a + /// fourth seam is needed beyond the three color-computation ones above: + /// those three answer "what RGB is this swatch", this one answers + /// "what actual bitmap should this element's face show" — a materially + /// different question once the answer can no + /// longer be a plain multiply-tint (see R3-5's finding). + /// + internal IChargenSwatchTextureSource? SwatchTextureSource { get; set; } + /// The authored viewport (0x100003bb) — the composition /// root assigns its Renderer once the graphics backend exists, /// mirroring the paperdoll's own late viewport.Renderer = ... @@ -759,29 +781,42 @@ internal sealed class CharacterCreationAppearancePage : IDisposable if (_swatches[i] is not { } swatch) continue; bool visible = i < displayCount; - swatch.Visible = visible; - // Closeout Group 1: a genuine multiplicative sprite tint on the - // swatch's own authored spot art (UiButton.Tint), replacing the - // Batch G flat-fill overlay. Vector4.One (identity) reproduces - // the swatch's bare authored art untouched — both "no color data - // yet" (sources unwired) and "beyond this part's color count". + // R3-5 correction: retail's own pColor->SetVisible(1) + // (DoColorSpots' own loop) is UNCONDITIONAL for all 9 swatches + // — beyond displayCount shows the BLANK/blocked art (still + // visible), never hides the element. Batch C's own "beyond + // count -> hide" half is retired by this correction. + swatch.Visible = true; + // Closeout Group 1 (Tint) + R3-5 correction (ColorKeyFaceResolver): + // Tint keeps communicating "this swatch's color is X" for every + // existing reader/test; the resolver is the SEPARATE decision + // of which pre-baked bitmap actually draws (see + // UiButton.ColorKeyFaceResolver's own doc for why a multiply + // over the authored sprite is retail-wrong here). ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null; swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One; + swatch.ColorKeyFaceResolver = BuildSwatchTextureResolver(visible, rgb); } - // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL): - // gmCGAppearancePage::DoGradDisk @0x0047da90 blits the blank "grad - // plug" for Eyes (DoGradDisk(this, 1), called from SetSelection + // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL) + R3-6 correction: + // gmCGAppearancePage::DoGradDisk @0x0047da90 blits the "grad plug" + // icon for Eyes (DoGradDisk(this, 1), called from SetSelection // @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY // SELECTED swatch's own color otherwise (SetColor @0x0047dd50's // tail, DoGradDisk(this, 0) after m_iCurColor is already updated — // @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0 // (SetSelection hard-codes eyeColor = 0 for those three cases, - // matching displayCount's own reasoning above). + // matching displayCount's own reasoning above). R3-6: SetSelection + // @0x0047e260's own Eyes/non-Eyes branches (@0x0047e859-0047e878) + // call ONLY DoGradDisk + m_pShadeScroll->SetVisible — NEITHER + // branch ever calls m_pGradCircle->SetVisible; the disc element + // itself is never hidden for Eyes, only its CONTENT (source image) + // changes. The prior "_gradCircle.Visible = !isEyes" line was a + // misreading — corrected to always-visible. if (_gradCircle is not null) { bool isEyes = _currentPart == Part.Eyes; - _gradCircle.Visible = !isEyes; + _gradCircle.Visible = true; // Closeout Group 1: same Tint mechanism as the swatches above — // the gradient disc's own authored art is multiplied by the // currently-selected swatch's color instead of an overlay child. @@ -793,6 +828,16 @@ internal sealed class CharacterCreationAppearancePage : IDisposable ChargenSwatchRgb? gradColor = gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null; _gradCircle.Tint = gradColor is { } gc ? ToTintColor(gc) : Vector4.One; + // R3-6: the disc's own authored media is empty (live-DAT- + // confirmed) — supply the missing base bitmap. Non-Eyes shows + // gradDisk (multiplied by Tint above, matching retail's own + // Blit_Multiply); Eyes shows the static plug icon UNTINTED + // (Tint is already Vector4.One for Eyes via gradIndex==-1 + // above, matching retail's plain Blit_Normal). + uint gradTexture = SwatchTextureSource is { } textures + ? (isEyes ? textures.GradPlugTexture : textures.GradDiskTexture) + : 0u; + _gradCircle.RuntimeImageTexture = gradTexture; } ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); @@ -812,6 +857,40 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } } + /// + /// R3-5: which pre-baked bitmap (if any) a swatch's own + /// should resolve to on + /// THIS refresh. A fresh closure per call (not a cached delegate) so a + /// later assignment (the composition + /// root wires it once the graphics backend exists, strictly after this + /// page's own construction — same ordering as + /// ) is picked up the next time the + /// resolver actually RUNS (at draw time), not frozen at the OLD (null) + /// value from an earlier refresh. + /// + /// Beyond displayCount ( + /// false): always resolve to the BLOCKED/blank art — retail shows this + /// for every swatch its current color count doesn't reach. + /// In range with a real color: resolve to that + /// color's own baked spot. + /// In range but no color data yet (palette seams + /// unwired): null — falls back to the ordinary FaceFileOverride/ + /// ActiveFile draw, which shows the swatch's own AUTHORED DirectState + /// sprite (the raw, un-recolored spot template — live-DAT-confirmed + /// present, see 's own doc) untinted, + /// matching this page's pre-R3-5 "fully inert until wired" + /// disposition for the OTHER three palette seams. + /// + /// + private Func? BuildSwatchTextureResolver(bool visible, ChargenSwatchRgb? rgb) + { + if (!visible) + return () => SwatchTextureSource?.BlankSpotTexture ?? 0u; + if (rgb is { } c) + return () => SwatchTextureSource?.GetActiveSpotTexture(c) ?? 0u; + return null; + } + // ── Real swatch/gradient colors (R2-5) ────────────────────────────── private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length]; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index b728e6ee..7cbe7810 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -386,6 +386,16 @@ internal sealed class CharacterCreationUiController : IDisposable set => _appearancePage.PaletteColorSource = value; } + /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth + /// late-bound seam, same pattern as the three above — see + /// 's + /// own doc comment. + internal IChargenSwatchTextureSource? AppearanceSwatchTextureSource + { + get => _appearancePage.SwatchTextureSource; + set => _appearancePage.SwatchTextureSource = value; + } + /// Gates the Appearance preview's per-frame work on whether /// that specific page — AND the whole chargen screen — is the one /// currently showing. Close() only ever hides , diff --git a/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs b/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs new file mode 100644 index 00000000..08aef07c --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; +using AcDream.App.Rendering; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.UI.Layout; + +/// +/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) seam: the pre-baked +/// textures 's color-wheel +/// swatches and gradient disc draw instead of a plain multiply-Tint +/// over the authored sprite. See 's +/// own doc for why a plain multiply is wrong here (it cannot recolor a +/// BLACK placeholder region at all, and it corrupts the ring border's own +/// colors). +/// +internal interface IChargenSwatchTextureSource +{ + /// + /// Retail's "blank"/blocked swatch art (enum 0x1000000f, + /// category 7 — gmCGAppearancePage::DoColorSpots @0x0047d850's + /// i >= count branch) — shown UNTINTED for a swatch beyond the + /// current part's real color count (retail's own + /// pColor->SetVisible(1) is unconditional for all 9 swatches; + /// only the CONTENT differs). 0 if unresolved. + /// + uint BlankSpotTexture { get; } + + /// + /// Retail's gradient-disc art (enum 0x1000000e, category 7) — + /// shown MULTIPLY-tinted by the currently selected swatch's own color, + /// matching retail's own SurfaceWindow::BlitAndColor(..., + /// Blit_Multiply, color) (DoGradDisk @0x0047da90's non-Eyes + /// branch) — a genuine multiply, unlike the swatch spots. 0 if + /// unresolved. + /// + uint GradDiskTexture { get; } + + /// + /// Retail's Eyes "grad plug" icon art (enum 0x10000010, category + /// 7) — shown UNTINTED (DoGradDisk's Eyes branch is a plain + /// Blit_Normal, no color argument at all). 0 if unresolved. + /// + uint GradPlugTexture { get; } + + /// + /// Bakes (or returns a cached) recolored copy of the ACTIVE swatch spot + /// template (enum 0x1000000d, category 7) with every EXACT-black + /// pixel replaced by 's own bytes (alpha and every + /// non-black pixel — the ring border — left untouched), matching retail's + /// SurfaceWindow::ReplaceColor call against old-color + /// (0,0,0,1). 0 if the template is unresolved. + /// + uint GetActiveSpotTexture(ChargenSwatchRgb rgb); +} + +/// +/// Live-DAT implementation of . +/// Decodes each of the four DoColorSpots/DoGradDisk category-7 +/// RenderSurfaces ONCE (live-DAT-measured: spot/blank are 37x44, gradDisk/ +/// gradPlug are 110x112 — exactly matching the swatch buttons' and grad +/// circle's own authored rects), uploads the three static ones (blank/ +/// gradDisk/gradPlug) once, and bakes+caches one recolored spot texture per +/// distinct value on demand — mirroring the +/// SAME "decode once, composite/recolor per key, upload, cache" shape +/// already established for item +/// icons and spell components (that class's own +/// GetSpellComponentIcon ports the identical exact-color-match +/// replace this class uses, just matching white instead of black). +/// +internal sealed class ChargenColorSpotComposer : IChargenSwatchTextureSource +{ + private const uint SpotEnumId = 0x1000000Du; + private const uint BlankEnumId = 0x1000000Fu; + private const uint GradDiskEnumId = 0x1000000Eu; + private const uint GradPlugEnumId = 0x10000010u; + private const uint EnumCategory = 7u; + + private readonly IDatReaderWriter _dats; + private readonly TextureCache _cache; + + private DecodedTexture? _spotTemplate; + private bool _spotResolveTried; + private readonly Dictionary<(byte R, byte G, byte B), uint> _bakedSpotByColor = new(); + + private uint _blankTexture; + private bool _blankResolveTried; + private uint _gradDiskTexture; + private bool _gradDiskResolveTried; + private uint _gradPlugTexture; + private bool _gradPlugResolveTried; + + public ChargenColorSpotComposer(IDatReaderWriter dats, TextureCache cache) + { + _dats = dats; + _cache = cache; + } + + public uint BlankSpotTexture + { + get + { + if (!_blankResolveTried) + { + _blankResolveTried = true; + if (TryDecode(BlankEnumId, out DecodedTexture decoded)) + _blankTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true); + } + return _blankTexture; + } + } + + public uint GradDiskTexture + { + get + { + if (!_gradDiskResolveTried) + { + _gradDiskResolveTried = true; + if (TryDecode(GradDiskEnumId, out DecodedTexture decoded)) + _gradDiskTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true); + } + return _gradDiskTexture; + } + } + + public uint GradPlugTexture + { + get + { + if (!_gradPlugResolveTried) + { + _gradPlugResolveTried = true; + if (TryDecode(GradPlugEnumId, out DecodedTexture decoded)) + _gradPlugTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true); + } + return _gradPlugTexture; + } + } + + public uint GetActiveSpotTexture(ChargenSwatchRgb rgb) + { + if (!_spotResolveTried) + { + _spotResolveTried = true; + if (TryDecode(SpotEnumId, out DecodedTexture decoded)) + _spotTemplate = decoded; + } + if (_spotTemplate is not { } template) + return 0u; + + var key = (rgb.R, rgb.G, rgb.B); + if (_bakedSpotByColor.TryGetValue(key, out uint cached)) + return cached; + + byte[] baked = ReplaceExactBlackWithColor(template.Rgba8, rgb); + uint texture = _cache.UploadRgba8(baked, template.Width, template.Height, nearest: true); + _bakedSpotByColor[key] = texture; + return texture; + } + + /// + /// Pure byte-level half of — cloned, + /// GL-free, and unit-testable without a TextureCache. Retail's + /// own old-color argument to SurfaceWindow::ReplaceColor is + /// RGBAColor(0,0,0,1) — opaque black, ALL four channels, not + /// just RGB (the decompiled float quad's own alpha term is + /// 0x3f800000 = 1.0) — so a genuinely transparent padding pixel + /// (alpha 0, also RGB-zero in this port's own decoded padding) does + /// NOT match and is left untouched, exactly like the ring border. + /// Every matched pixel's RGB becomes 's own + /// bytes and alpha is forced to fully opaque (retail's own new-color + /// argument is ALSO alpha 1 — SetColor's computed swatch color + /// carries a hardcoded opaque alpha, not the source pixel's). Mirrors + /// 's + /// own exact-match convention (there, pure white) rather than an + /// invented fuzzy tolerance. + /// + internal static byte[] ReplaceExactBlackWithColor(byte[] rgba, ChargenSwatchRgb rgb) + { + byte[] baked = (byte[])rgba.Clone(); + for (int i = 0; i + 3 < baked.Length; i += 4) + { + if (baked[i] != 0 || baked[i + 1] != 0 || baked[i + 2] != 0 || baked[i + 3] != 255) + continue; + baked[i] = rgb.R; + baked[i + 1] = rgb.G; + baked[i + 2] = rgb.B; + baked[i + 3] = 255; + } + return baked; + } + + private bool TryDecode(uint enumId, out DecodedTexture decoded) + { + decoded = null!; + uint did = RetailDataIdResolver.Resolve(_dats, enumId, EnumCategory); + if (did == 0) return false; + if (!_dats.TryGet(did, out var rs) || rs is null) return false; + decoded = SurfaceDecoder.DecodeRenderSurface(rs); + return true; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index a6f56b47..da1e74b3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -715,6 +715,20 @@ public sealed class RetailUiRuntime : IDisposable } } + /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth + /// late-bound seam, same pattern as the three above — see + /// 's + /// own doc comment. + internal AcDream.App.UI.Layout.IChargenSwatchTextureSource? ChargenSwatchTextureSource + { + get => CharacterCreationController?.AppearanceSwatchTextureSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearanceSwatchTextureSource = value; + } + } + /// CC6b-MOUNT: whether the Appearance page (specifically) is /// the one currently showing — false, safely, before the screen mounts. /// diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs index fc81db24..06cc80db 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationAppearancePageSwatchColorTests.cs @@ -252,9 +252,12 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests Assert.True(Swatch(root, 0).Visible); Assert.Equal(ToVector4(HairColorB), Swatch(root, 1).Tint); Assert.True(Swatch(root, 1).Visible); - // Only two hair colors exist — swatch 2 must be hidden and untinted. + // R3-5 correction: only two hair colors exist, so swatch 2 shows + // the BLOCKED/blank art (untinted) — but retail's own + // pColor->SetVisible(1) is unconditional, so it stays VISIBLE, not + // hidden (Batch C's "beyond count -> hide" half is retired). Assert.Equal(Vector4.One, Swatch(root, 2).Tint); - Assert.False(Swatch(root, 2).Visible); + Assert.True(Swatch(root, 2).Visible); } /// Part change (Hair -> Eyes via the spin's own select-zone @@ -285,9 +288,9 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); page.Refresh(view, view.Snapshot); // No color selected yet (Unset) — no tint, but the disc's own base - // art is still shown (Visible tracks !isEyes only, independent of - // whether a real color has been resolved — the disc is never - // hidden pending a tint, only Eyes hides it at all). + // art is still shown (R3-6: the disc is ALWAYS visible — retail + // never hides it, for Eyes or otherwise — independent of whether a + // real color has been resolved). Assert.Equal(Vector4.One, GradCircle(root).Tint); Assert.True(GradCircle(root).Visible); @@ -309,10 +312,20 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests Assert.Equal(ToVector4(HairColorB), GradCircle(root).Tint); } - /// AP-217: Eyes always hides the gradient disc — no tint is - /// ever shown for Eyes, regardless of the selected eye color. + /// + /// R3-6 correction (Campaign CC gate round 1 re-test 2), supersedes the + /// retired AP-217 "Eyes always hides the gradient disc" claim: re- + /// reading gmCGAppearancePage::SetSelection @0x0047e260's own + /// Eyes/non-Eyes tail (@0x0047e859-0047e878) shows NEITHER + /// branch ever calls m_pGradCircle->SetVisible — only + /// DoGradDisk (which swaps the SOURCE image) and + /// m_pShadeScroll->SetVisible (a DIFFERENT element, the shade + /// slider) are touched. The disc stays visible for Eyes too, showing + /// the static "grad plug" icon UNTINTED (Blit_Normal, no color arg) — + /// the swatches themselves still show real eye colors regardless. + /// [Fact] - public void EyesPart_GradientDiscStaysHiddenAndUntinted() + public void EyesPart_GradientDiscStaysVisibleButUntinted_ShowsPlugIconInstead() { var (pal, clothing, colors) = MakeSources(); (CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors); @@ -326,10 +339,10 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId)); eyesSpin.OnClickAt!(180, 10); - Assert.False(GradCircle(root).Visible); + Assert.True(GradCircle(root).Visible); Assert.Equal(Vector4.One, GradCircle(root).Tint); // The swatches themselves still show real eye colors — only the - // disc hides. + // disc's own SOURCE image swaps (plug icon, not the gradient). Assert.Equal(ToVector4(EyeColorA), Swatch(root, 0).Tint); } @@ -350,8 +363,10 @@ public sealed class CharacterCreationAppearancePageSwatchColorTests Assert.Equal(ToVector4(SkinColor), Swatch(root, 0).Tint); Assert.True(Swatch(root, 0).Visible); + // R3-5 correction: swatch 1 is beyond the 1-color Nose/Mouth/Skin + // display count — untinted (blocked art), but still VISIBLE. Assert.Equal(Vector4.One, Swatch(root, 1).Tint); - Assert.False(Swatch(root, 1).Visible); + Assert.True(Swatch(root, 1).Visible); Assert.Equal(ToVector4(SkinColor), GradCircle(root).Tint); Assert.True(GradCircle(root).Visible); } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs new file mode 100644 index 00000000..835a2546 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChargenColorSpotComposerTests.cs @@ -0,0 +1,101 @@ +using AcDream.App.UI.Layout; +using AcDream.Core.CharGen; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): pure byte-level tests +/// for — +/// no TextureCache/GL/dat needed. Live-DAT enum resolution + +/// dimension pins live in CharacterCreationLiveDatTests. +/// +public sealed class ChargenColorSpotComposerTests +{ + private static byte[] Pixels(params (byte r, byte g, byte b, byte a)[] pixels) + { + var buffer = new byte[pixels.Length * 4]; + for (int i = 0; i < pixels.Length; i++) + { + buffer[i * 4] = pixels[i].r; + buffer[i * 4 + 1] = pixels[i].g; + buffer[i * 4 + 2] = pixels[i].b; + buffer[i * 4 + 3] = pixels[i].a; + } + return buffer; + } + + [Fact] + public void ReplaceExactBlackWithColor_RecolorsOnlyOpaqueExactBlackPixels_LeavesEverythingElseUntouched() + { + var rgb = new ChargenSwatchRgb(200, 40, 90); + // Pixel 0: exact black, OPAQUE (the spot's placeholder fill) -> recolored. + // Pixel 1: a gold ring-border pixel -> untouched. + // Pixel 2: near-black but NOT exact (a hypothetical anti-aliased edge) -> untouched + // (exact match only, matching IconComposer's own precedent). + // Pixel 3: fully transparent padding, RGB also zero -> untouched — retail's own + // old-color argument is opaque black (alpha 1), so a transparent pixel + // does not match even though its RGB is zero. + byte[] source = Pixels( + (0, 0, 0, 255), + (218, 167, 85, 255), + (2, 1, 0, 255), + (0, 0, 0, 0)); + + byte[] baked = ChargenColorSpotComposer.ReplaceExactBlackWithColor(source, rgb); + + Assert.Equal(rgb.R, baked[0]); + Assert.Equal(rgb.G, baked[1]); + Assert.Equal(rgb.B, baked[2]); + Assert.Equal(255, baked[3]); + + Assert.Equal(218, baked[4]); + Assert.Equal(167, baked[5]); + Assert.Equal(85, baked[6]); + Assert.Equal(255, baked[7]); + + Assert.Equal(2, baked[8]); + Assert.Equal(1, baked[9]); + Assert.Equal(0, baked[10]); + Assert.Equal(255, baked[11]); + + Assert.Equal(0, baked[12]); + Assert.Equal(0, baked[13]); + Assert.Equal(0, baked[14]); + Assert.Equal(0, baked[15]); // still transparent — not force-opaqued + } + + [Fact] + public void ReplaceExactBlackWithColor_DoesNotMutateTheSourceBuffer() + { + byte[] source = Pixels((0, 0, 0, 255)); + byte[] sourceCopy = (byte[])source.Clone(); + + _ = ChargenColorSpotComposer.ReplaceExactBlackWithColor(source, new ChargenSwatchRgb(10, 20, 30)); + + Assert.Equal(sourceCopy, source); + } + + [Fact] + public void ReplaceExactBlackWithColor_EveryOpaqueBlackPixelRecolored_SemiTransparentBlackLeftAlone() + { + // Pixels 0/1: opaque black -> recolored. Pixel 2: semi-transparent + // black (alpha 128, not the retail old-color's exact alpha-1) -> + // untouched, same "exact match only" discipline as the RGB channels. + byte[] source = Pixels((0, 0, 0, 255), (0, 0, 0, 255), (0, 0, 0, 128)); + var rgb = new ChargenSwatchRgb(9, 8, 7); + + byte[] baked = ChargenColorSpotComposer.ReplaceExactBlackWithColor(source, rgb); + + for (int i = 0; i < 2; i++) + { + Assert.Equal(rgb.R, baked[i * 4]); + Assert.Equal(rgb.G, baked[i * 4 + 1]); + Assert.Equal(rgb.B, baked[i * 4 + 2]); + Assert.Equal(255, baked[i * 4 + 3]); + } + Assert.Equal(0, baked[8]); + Assert.Equal(0, baked[9]); + Assert.Equal(0, baked[10]); + Assert.Equal(128, baked[11]); // untouched, including its own alpha + } +} From 91f84dec9f5a8c7fe04148b2c857459508a7736d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 17:20:42 +0200 Subject: [PATCH 134/138] =?UTF-8?q?test(chargen):=20Campaign=20CC=20gate?= =?UTF-8?q?=20round=201=20re-test=202=20=E2=80=94=20R3-8=20name-field=20ex?= =?UTF-8?q?haustive=20dump=20+=20closeout=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3-8: dumped EVERY property present on 0x10000402 (not just P0x17) across every state, cross-referenced against UIElement_Text::OnSetAttribute's complete case list (no UIElement_TextInput class exists in retail — the name field is a plain UIElement_Text/m_filter-bearing field). The full recognized-property space has no placeholder/prompt mechanism independent of P0x17. The BaseElement/prototype-inheritance hypothesis is also ruled out — the existing regression test already probes the fully-merged ElementInfo (post BaseElement resolution) and finds nothing. The only StringInfo-kind property present, 0x49, resolves to "Your name can be 32 characters long and cannot contain numbers or symbols." — but 0x49 is part of the same five-property tooltip family ISSUES #409/GF-16 already document client-wide (0x48's own DID, 0x21000041, is the EXACT tooltip popup LayoutDesc #409 cites) — a hover tooltip, not an in-field placeholder. No code change, per this batch's own "do not invent a placeholder" contract — third independent negative result on this question via three different mechanisms. The lead should request a live retail screenshot before any further investigation. Also carries the shared live-DAT regression suite for R3-1 through R3-7 (CharacterCreationLiveDatTests.cs holds tests spanning multiple findings in one file, so they land together) and the RE-TEST 2 findings-doc closeout writeup for all eight items. App suite live-DAT env 5358/3 -> 5372/3 (+14, zero regressions). Runtime 1735/0 unchanged (untouched this round). Full solution: 14578 tests / 4 skips / 0 failures. Co-Authored-By: Claude Fable 5 --- ...-08-16-campaign-cc-gate-round1-findings.md | 246 +++++++++++++- .../Layout/CharacterCreationLiveDatTests.cs | 317 ++++++++++++++++++ 2 files changed, 562 insertions(+), 1 deletion(-) diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index a19b6023..734ed3e3 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1,6 +1,6 @@ # Campaign CC connected gate — round 1 findings (2026-08-16) -## RE-TEST 2 (build `1.0.2-cc.k`, post-E/F/G/closeout) — findings R3-1..R3-9 +## RE-TEST 2 (build `1.0.2-cc.k`, post-E/F/G/closeout) — findings R3-1..R3-8 Heritage PASSES. Remaining, with retail side-by-side screenshots: @@ -40,6 +40,250 @@ Heritage PASSES. Remaining, with retail side-by-side screenshots: is also empty, STOP and request a live retail screenshot of the field before any further work. +**RE-TEST 2 fix batch (2026-08-16, R3-1..R3-8) is CODE-COMPLETE, pending the +user's visual gate.** All eight findings investigated and fixed except R3-8 +(genuinely no dat-authored placeholder mechanism exists — see its own +disposition below). App suite live-DAT env 5358/3 → 5372/3 (+14, zero +regressions); Runtime 1735/0 unchanged (untouched this round); full +solution 14578 tests / 4 skips / 0 failures (0 Core/Content changes this +round, so those suites are unaffected by construction, not merely by +measurement). No client launches. + +- **R3-1/R3-2 FIXED.** Root cause: Batch E's `UiButton.DrawBlockLabel`/ + `WrapBlockLines` auto-wrapped ANY caption that didn't fit its box width — + live-DAT-probed, this is the WRONG mechanism. `UIElement_Text:: + CalcJustification @0x00467260` (shared by the horizontal/vertical + branches) shows retail's real per-glyph break decision — BOTH the + width-triggered wrap AND the explicit-newline break — sits behind ONE + gate keyed on the `OneLine` flag passed into `GlyphList::Recalculate`; + nothing in the decomp confines a caption's wrap width to a SIBLING + element's rect (the ValueBox confinement Batch E added for the + coexisting-value-label shape). Live-DAT evidence: the Coordination + attribute-slider label (`0x100002ed`) authors `OneLine=true` (so it + should never wrap, regardless of width — decomp-confirmed, not just + measurement); the Skills credits button's "Available Skill Credits" + caption measures 193px against its OWN full 231px button width (fits + comfortably) — the 113px confined width Batch E fed into the wrap + decision was never a real retail quantity. Fixed by making + `UiButton.WrapBlockLines` split ONLY on the explicit (already- + normalized) `\n` — never width-based — a strict superset of the + pre-Batch-E single-line draw for every already-correct caption, and the + exact "Attribute\n Credits" authored-break shape still works unchanged. + The `ValueBox` confinement computation itself STAYS in + `UiButton.OnDraw` (still feeds the Center-alignment tx formula and the + now-unreachable-for-wrap clip rect for the rare multi-line+ValueBox + case) — deliberately not deleted, since it's harmless now that nothing + reads it for the wrap decision, and removing it would be unrelated + scope. **Known residual risk, NOT re-solved here:** the original + Batch E root-cause diagnosis for R2-2/R2-3 ("24dits"/"Credit0Credits") + was that the caption's own unconfined single-line render visually + overlapped the coexisting value label's rect (live-DAT-measured: the + Skills credits caption's rendered span reaches x≈196, the value box + starts at local x=116). Removing the WRAP does not reintroduce a + confinement CLIP either (deliberately — see `DrawBlockLabel`'s own doc + for why inventing a new clip boundary here would be exactly the + guessing this project forbids), so this specific button's caption and + value MAY visually overlap again in the live client. This was NOT + something the current re-test (R3-2) flagged as broken — it only + reported the wrap — so no action was taken beyond documenting the risk + for the user's own re-check. + Files: `src/AcDream.App/UI/UiButton.cs` (`DrawBlockLabel`, + `WrapBlockLines`, `OnDraw`'s Label block). Tests: `UiButtonTests.cs` + (`WrapBlockLines_LongSingleParagraph_NeverWordWraps` replaces the retired + Batch E word-wrap expectation; new + `WrapBlockLines_SingleWordNarrowerThanBoxButWiderThanOffsetAdjustedWidth_StaysOneLine`); + 2 new live-DAT pins in `CharacterCreationLiveDatTests.cs` + (`CoordinationAttributeLabel_AuthorsOneLineTrue`, + `SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint`). + +- **R3-3 FIXED.** Root cause: the title (`0x100003fb`, Y=435 H=100) and + description (`0x100003fc`, Y=460 H=100) panes' own AUTHORED boxes + overlap by 75px, live-DAT-measured — retail relies on VERTICAL + JUSTIFICATION, not disjoint rects, to keep them visually separate. + Neither pane authors dat property `0x15` (live-DAT-confirmed absent on + both), so both fall to this port's shared unauthored-VJustify default — + currently `Center`. Byte-tracing retail's real ctor default + (`UIElement_Text::UIElement_Text @0x004685ff`, + `m_eVerticalJustification = 4`) against `UIElement_Text:: + CalcJustification @0x00467260`'s ACTUAL enum semantics (`ecx_5==1` → + Center; `ecx_5==3||5` → the far edge/Bottom; anything else, INCLUDING + the ctor's own default of 4 → the near edge/Top) shows the correct + unauthored default is **Top, not Center** — a genuine, client-wide + enum-mapping bug in this port (`ElementReader.cs`'s import-time switch, + `DatWidgetFactory.cs`'s build-time switch, and `ElementInfo.VJustify`'s + field default all currently resolve an absent `0x15` to Center). Under + the CORRECT Top default both panes render near their own box's TOP edge + (25px apart — no collision); under the current Center default both + cluster toward the middle of their overlapping boxes (collision). + **Scoped fix, not the systemic one:** `CharacterCreationSkillsPage`'s + constructor now force-sets `VerticalJustify = VJustify.Top` on both + panes directly, rather than fixing the shared mapping/default. The + shared bug is CLIENT-WIDE (every DAT-imported `UiText` reaching the + `Centered`/`RightAligned`/`OneLine` static paths or the multi-line + honored-justification path) and could regress already-shipped, visually + -verified, FROZEN surfaces (vitals numbers, chat, main game UI, Options + panel) that may rely on the CURRENT Center default — fixing it properly + needs its own dedicated investigation + full regression sweep, filed as + **ISSUES.md #410** and register **AD-104**. + Files: `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` + (constructor). Tests: new + `CharacterCreationUiControllerTests.SkillsPage_InfoBoxPanes_ForceTopVerticalJustify_ToAvoidTitleDescriptionOverlap` + (fixture); new live-DAT + `CharacterCreationLiveDatTests.SkillsInfoBoxTitleAndDescription_AuthoredBoxesOverlap` + (pins the overlap premise itself, so a future DAT re-extract that makes + the boxes genuinely disjoint is visible). + +- **R3-4/R3-7 FIXED (single shared mechanism, confirmed).** Root cause: + retail authors TWO DISTINCT `UIElement_Scrollbar` thumb shapes. + `DatWidgetFactory.BuildScrollbar`'s existing vertical-thumb detection was + built against chat's own scrollbar (`0x10000012`) — a 3-slice composite + where the thumb CHILD carries no media of its own and three Type-3 + grandchildren supply the top-cap/middle/bottom-cap sprites. The chargen + Skills listbox scrollbar (`0x100003f8`), Summary's OVERVIEW listbox + scrollbar (`0x10000401`), the Summary how-to box's scrollbar + (`0x100002e7`), AND the shade slider (`0x10000321`) all instead author a + SIMPLE single-sprite thumb: the same structural child (Type 1, id 1, not + the inc/dec button) carries its OWN direct Normal/Normal_rollover/ + Normal_pressed (or, for the shade slider, a single DirectState) media + and has ZERO children — the 3-slice-only search found nothing for this + shape, so every `Thumb*Sprite` stayed 0 regardless of overflow. Fixed by + falling back to the thumb's own `DefaultImage` when the slice search + finds nothing — additive; a thumb WITH real slice children (chat) is + unaffected. This ONE fix covers R3-4's three listbox thumbs AND R3-7 + AND, as a natural consequence (same code path, same structural shape), + the shade-slider indicator half of R3-5(c) — no separate fix was needed + for the shade slider. + **Process note:** the ORIGINAL live-DAT probe for this investigation + mis-reported the shade slider's own thumb child (and, separately, the + swatch/gradCircle elements investigated for R3-5/R3-6) as authoring + "zero media" — a `string.Join(",", StateMedia.Keys)` display artifact + (a single `""` DirectState key joins to an EMPTY STRING, indistinguishable + from zero entries in a printed diagnostic — not a code defect, a + diagnostic-only mistake caught and corrected mid-investigation by + re-probing with an exact `.Count`/dictionary-content check instead of a + joined string). + Files: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` + (`BuildScrollbar`). Tests: new `DatWidgetFactoryTests.cs` + (`Type11_VerticalScrollbar_SingleSpriteThumbWithNoSliceChildren_SetsThumbSprite`, + `Type11_VerticalScrollbar_ThumbWithSliceChildren_StillUsesSliceMedia` + negative companion); 3 new live-DAT pins in + `CharacterCreationLiveDatTests.cs` + (`SkillsListboxScrollbar_SingleSpriteThumbShape_BuildsWithNonZeroThumbSprite`, + `ShadeSlider_ThumbAuthorsItsOwnDirectStateSprite_BuildsWithNonZeroThumbSprite`); + existing `ChatFixture_ScrollbarImportsInheritedMediaRoles` (3-slice shape) + re-verified unaffected. + +- **R3-5/R3-6 CODE-COMPLETE.** Root cause, re-derived from + `gmCGAppearancePage::DoColorSpots @0x0047d850` and `DoGradDisk + @0x0047da90`: retail does NOT multiply-tint the swatch/grad-circle's + authored sprite. It builds a FRESH composited surface once + (`CreateLocalSurface` + `Blit`), then calls `SurfaceWindow::ReplaceColor` + against old-color `RGBAColor(0,0,0,1)` (opaque black — the spot + template's own placeholder fill, live-DAT-PIXEL-confirmed: the + 37x44 "spot" resource has a genuine solid-black CENTER region and a + genuine non-black RING/border region) — swapping every EXACT opaque-black + pixel for the swatch's real color while leaving the ring untouched. A + multiply-tint (Batch G's mechanism) is architecturally wrong here: black + multiplied by ANY color stays black (never recolors the center at all), + and multiplying the ring's own non-black pixels shifts their hue, + corrupting them — exactly the reported "we tint the ring" symptom. + Beyond-count swatches (R3-5b) use a COMPLETELY DIFFERENT authored + resource (enum `0x1000000f`, "blank" — live-DAT-pixel-confirmed almost + no black pixels at all, i.e. genuinely different art, not "the spot with + its center left un-recolored") shown UNTINTED — and retail's own + `pColor->SetVisible(1)` is UNCONDITIONAL for all 9 swatches (never + hidden, only the CONTENT differs). For Eyes (R3-6), `DoGradDisk`'s Eyes + branch (`arg2=1`) blits the "grad plug" icon (enum `0x10000010`) + UNTINTED (`Blit_Normal`, no color argument at all) — and, re-reading + `SetSelection @0x0047e260`'s own Eyes/non-Eyes tail + (`@0x0047e859-0047e878`), NEITHER branch ever calls + `m_pGradCircle->SetVisible` — only `DoGradDisk` (swaps the source image) + and `m_pShadeScroll->SetVisible` (a DIFFERENT element) are touched, so + the disc itself is NEVER hidden for Eyes — a correction to this port's + prior `_gradCircle.Visible = !isEyes` line (and its own now-renamed + test). + **Mechanism ported faithfully** via a new `ChargenColorSpotComposer` + (CPU-side decode-once + per-color bake-and-cache-once, uploaded through + the existing `TextureCache.UploadRgba8` seam — the SAME "decode, + recolor by exact-match, upload, cache" shape `AcDream.App.UI. + IconComposer.GetSpellComponentIcon` already established for item icons, + just matching black instead of white) and a new opt-in + `UiButton.ColorKeyFaceResolver`/reuse of the EXISTING + `UiDatElement.RuntimeImageTexture` seam — both additive, zero behavior + change for any element that doesn't set them. `Tint` itself is + UNCHANGED in meaning (still the "this swatch's color is X" signal every + existing test reads) for the swatches; the grad circle's own Tint STAYS + a genuine multiply for the non-Eyes case, matching retail's OWN + `Blit_Multiply` there (the ONE place a multiply tint is actually + correct). Wired as a fourth late-bound composition seam + (`SwatchTextureSource`), same pattern and same site as the existing + three color-computation seams (`PalSetSource`/`ClothingTableSource`/ + `PaletteColorSource`), constructed in `LivePresentationComposition.cs` + once `TextureCache` exists. + **STOPPED item, same shape as Batch G's own two STOPPED items:** this + is CODE-COMPLETE and unit/live-DAT-tested (including a real pixel-level + proof that the spot template genuinely has a black center + non-black + ring, and that the blank template genuinely doesn't), but has NOT been + visually verified in the live client this round (no client launches per + this batch's own constraint) — the user's connected gate is owed. + Files: `src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs` (new), + `src/AcDream.App/UI/UiButton.cs` (`ColorKeyFaceResolver`, `OnDraw`), + `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` + (`SwatchTextureSource`, `BuildSwatchTextureResolver`, the swatch/ + gradCircle refresh block), `src/AcDream.App/UI/Layout/ + CharacterCreationUiController.cs` + `src/AcDream.App/UI/ + RetailUiRuntime.cs` (pass-through seam), `src/AcDream.App/Composition/ + LivePresentationComposition.cs` (composition-root wiring). Tests: new + `ChargenColorSpotComposerTests.cs` (3 pure byte-level tests for the + exact-match recolor), 4 live-DAT tests in `CharacterCreationLiveDatTests.cs` + (`ColorSpotAndGradDiskResources_ResolveToExpectedDimensions`, + `SpotTemplate_HasBlackCenterAndNonBlackRing_BlankTemplateHasNeitherBlack`), + 8 `CharacterCreationAppearancePageSwatchColorTests.cs` assertions updated + (`Visible` now always true; `EyesPart_GradientDiscStaysHiddenAndUntinted` + renamed+rewritten to `EyesPart_GradientDiscStaysVisibleButUntinted_ShowsPlugIconInstead`). + +- **R3-8 DISPOSITION: genuinely nothing authored — no code change, per + this batch's own contract ("DO NOT invent a placeholder").** Dumped + EVERY property present on `0x10000402` (not just P0x17) across every + state, cross-referenced against `UIElement_Text::OnSetAttribute`'s + COMPLETE case list (there is no `UIElement_TextInput` class in retail — + the name field is a plain `UIElement_Text`/`m_filter`-bearing field, + `DynamicCast(0xc)`-confirmed in `gmCGSummaryPage::InitializePage`; the + task's own reference to that class name doesn't exist in the named + decomp). The full recognized-property space (every id `0x14`-`0x29` + plus the sparse high ids `0xC7`/`0xCB`/`0xCC` for + TruncateTextToFit/LoseFocusOnEscape/LoseFocusOnAcceptInput) has NO + mechanism for a placeholder/prompt string independent of the committed + P0x17 caption. The BaseElement/prototype-inheritance hypothesis is ALSO + ruled out — not by assumption, but because the EXISTING regression test + (`SummaryNameField_AuthorsNoP0x17OnAnyState`) already probes the FULLY + MERGED `ElementInfo` (post `LayoutImporter`'s BaseElement resolution, + confirmed by reading `ElementReader.Merge`/`UiStateInfo.Merge`'s own + "derived overrides, else inherit base" property-bag semantics) and finds + no P0x17 anywhere. The live dump found exactly ONE StringInfo-kind + property on the whole element: **`0x49`, resolving to "Your name can be + 32 characters long and cannot contain numbers or symbols."** — but + `0x49` is part of the SAME five-property tooltip family ISSUES.md + #409/GF-16 already documents client-wide (`0x47` tooltip behavior + enum = `0x10000487`, `0x48` the tooltip popup LayoutDesc DID = + `0x21000041` — the EXACT DID #409 cites, `0x49` the tooltip TEXT, + `0x4B` tooltip-enabled = true) — a HOVER TOOLTIP describing naming + rules, not an in-field placeholder, and #409's tooltip system is + unshipped so this text is authored but never shown anywhere yet. The + field's 8 children are the SAME gold-frame family (`0x100002DE-E3`/ + `0x100000E8`/`0xEA`) GF-12 already renders — reinforcing GF-15's + existing hypothesis that the `[ Name` the user perceives is this frame's + own bracket-style chrome around an empty box, not text content. + **Batch A's closure and Batch E's re-check both stand; this is the + THIRD independent negative result on the same question via three + different mechanisms (retail code, DAT P0x17, now the complete property + space + inheritance chain).** No further code-side avenue remains — the + lead should request a live retail screenshot of the field per the + batch's own contract before any further investigation. + Test: `CharacterCreationLiveDatTests.SummaryNameField_AuthorsNoP0x17OnAnyState` + extended with the exhaustive `0x49`-is-the-tooltip assertion (a + regression pin, not just a probe finding). + ## ROUND 1 RE-TEST (build `1.0.2-cc.i`, post-Batches B/C/D) — findings R2-1..R2-8 User's second visual pass with retail side-by-side screenshots (heritage diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 435efab7..0cab5bd2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -944,6 +944,29 @@ public sealed class CharacterCreationLiveDatTests $"the name field's state 0x{stateId:X} ('{state.Name}') must not " + "author a P0x17 caption either."); } + + // R3-8 (re-test 2, third assertion): EXHAUSTIVE — every property + // this element authors, cross-referenced against + // UIElement_Text::OnSetAttribute's COMPLETE case list (every + // property id that class recognizes at all: 0x14-0x29 plus the + // sparse high ids 0xC7/0xCB/0xCC), not just P0x17. The ONLY + // StringInfo-kind property present is 0x49 — part of the tooltip + // family (0x47 TooltipBehavior/0x48 the tooltip popup LayoutDesc + // DID 0x21000041/0x49 the tooltip TEXT/0x4B TooltipEnabled — the + // SAME five-property family ISSUES.md #409/GF-16 already + // documents client-wide) and resolves to "Your name can be 32 + // characters long and cannot contain numbers or symbols." — a + // HOVER TOOLTIP, not an in-field placeholder; #409's tooltip + // system is unshipped, so this text is authored but never shown + // anywhere yet. No other property on this element (or its 8 gold- + // frame children, the SAME 0x100002DE-E3/0x100000E8/0xEA family + // GF-12 already renders) carries any string content. + Assert.True(nameField.TryGetEffectiveProperty(0x49u, out var tooltip)); + Assert.Equal(UiPropertyKind.StringInfo, tooltip.Kind); + var strings = new DatStringResolver(dats); + Assert.Equal( + "Your name can be 32 characters long and cannot contain numbers or symbols.", + strings.Resolve(tooltip.StringInfoValue)); } /// @@ -1583,6 +1606,272 @@ public sealed class CharacterCreationLiveDatTests + "regardless of the R2-1 margin fix"); } + /// + /// R3-1 (re-test 2): live-DAT pin for the wrap-mechanism fix's own + /// premise — the Coordination attribute-slider label (0x100002ed + /// under container 0x100003e8) authors OneLine=true (dat + /// property 0x20), the SAME retail default a caption with no + /// authored 0x20 resolves to for width-wrap purposes per + /// UIElement_Text::CalcJustification's per-glyph gate (see + /// 's own doc) — so this element + /// must never width-wrap regardless of font metrics. + /// + [InstalledDatFact] + public void CoordinationAttributeLabel_AuthorsOneLineTrue() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + ElementInfo coordContainer = Assert.IsType(FindInfo(rootInfo, 0x100003E8u)); + ElementInfo coordLabel = Assert.IsType(FindInfo(coordContainer, 0x100002EDu)); + + Assert.True(coordLabel.TryGetEffectiveBool(0x20u, out bool oneLine) && oneLine); + } + + /// + /// R3-2 (re-test 2): live-DAT pin for the wrap-mechanism fix's own + /// premise — the Skills credits button's caption ("Available Skill + /// Credits", 0x100003f9) fits comfortably inside the button's + /// own FULL authored width (never needing to wrap), and its value + /// child (0x100002f3) sits at local X=116 — the confinement + /// figure Batch E used to force a false wrap, no longer consulted for + /// the wrap decision post-fix (see 's + /// own doc). + /// + [InstalledDatFact] + public void SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + var strings = new DatStringResolver(dats); + + ElementInfo skillsCredits = Assert.IsType(FindInfo(rootInfo, 0x100003F9u)); + Assert.True(skillsCredits.TryGetEffectiveProperty(0x17u, out var caption)); + string? captionText = strings.Resolve(caption.StringInfoValue); + Assert.Equal("Available Skill Credits", captionText); + + uint fontDid = skillsCredits.FontDid != 0 ? skillsCredits.FontDid : rootInfo.FontDid; + Assert.True(dats.TryGet(fontDid, out var font) && font is not null); + var glyphs = new Dictionary(font!.CharDescs.Count); + foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd; + var datFont = new UiDatFont(0, 0, 0, 0, 0, 0, font.MaxCharHeight, font.BaselineOffset, glyphs); + float measured = datFont.MeasureWidth(captionText!); + + Assert.True( + measured < skillsCredits.Width, + $"caption measured {measured}px must fit the button's own full {skillsCredits.Width}px width"); + + ElementInfo valueChild = Assert.Single(skillsCredits.Children); + Assert.Equal(116f, valueChild.X); + } + + /// + /// R3-3 (re-test 2): the info-box title (0x100003fb) and + /// description (0x100003fc) panes' own AUTHORED boxes overlap — + /// this is why 's constructor + /// forces both to VJustify.Top rather than relying on disjoint + /// rects (see that constructor's own comment for the full decomp + /// citation). Pinned so a future DAT re-extract that changes these + /// boxes to genuinely disjoint rects is visible here, not silently + /// contradicting the fix's own premise. + /// + [InstalledDatFact] + public void SkillsInfoBoxTitleAndDescription_AuthoredBoxesOverlap() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + ElementInfo title = Assert.IsType(FindInfo(rootInfo, 0x100003FBu)); + ElementInfo description = Assert.IsType(FindInfo(rootInfo, 0x100003FCu)); + + // Neither pane authors an explicit vertical-justify property — both + // fall to this port's shared (currently Center) unauthored default, + // ISSUES.md #410. + Assert.False(title.TryGetEffectiveProperty(0x15u, out _)); + Assert.False(description.TryGetEffectiveProperty(0x15u, out _)); + + float titleBottom = title.Y + title.Height; + float descriptionTop = description.Y; + Assert.True( + titleBottom > descriptionTop, + $"expected the title's own box (Y={title.Y} H={title.Height}, bottom={titleBottom}) to " + + $"overlap the description's box (Y={description.Y}) — if it no longer does, the " + + "VerticalJustify.Top override in CharacterCreationSkillsPage may no longer be needed"); + // The two boxes' own TOP edges still leave enough of a gap for + // Top-justified content not to collide — the fix's actual premise. + Assert.True(description.Y > title.Y); + } + + /// + /// R3-4/R3-7 (re-test 2): retail authors TWO distinct + /// UIElement_Scrollbar thumb shapes. Chat's own scrollbar + /// (0x10000012 under LayoutDesc 0x2100006f) is the + /// 3-slice composite 's + /// original thumb-detection was built against (the thumb child itself + /// carries NO media; three Type-3 grandchildren supply the cap/middle/ + /// cap sprites). The chargen Skills listbox scrollbar (0x100003f8) + /// instead authors a SIMPLE single-sprite thumb: the same structural + /// child (Type 1, id 1, not the inc/dec button) carries its OWN direct + /// Normal/Normal_rollover/Normal_pressed media and has ZERO children — + /// before the fix, the 3-slice-only search found nothing and every + /// Thumb*Sprite stayed 0. This test builds the REAL scrollbar end to + /// end and asserts now resolves. + /// + [InstalledDatFact] + public void SkillsListboxScrollbar_SingleSpriteThumbShape_BuildsWithNonZeroThumbSprite() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiScrollbar scrollbar = Assert.IsType(screen.FindElement(0x100003F8u)); + Assert.False(scrollbar.Horizontal); + Assert.NotEqual(0u, scrollbar.ThumbSprite); + // The 3-slice caps stay unset for this shape — OnDraw's own + // single-tile fallback (ThumbTopSprite/ThumbBotSprite both 0) + // draws the whole thumb from ThumbSprite alone. + Assert.Equal(0u, scrollbar.ThumbTopSprite); + Assert.Equal(0u, scrollbar.ThumbBotSprite); + } + + /// + /// R3-5/R3-6 (re-test 2): live-DAT pin for + /// 's four hardcoded enum ids — + /// resolves each through the SAME category-7 RetailDataIdResolver + /// chain gmCGAppearancePage::DoColorSpots/DoGradDisk use, + /// and pins the native dimensions those two decomp functions' own + /// CreateLocalSurface calls size their composite surfaces to + /// (spot/blank match the swatch buttons' own 37x44 authored rect; + /// gradDisk/gradPlug match the grad circle's own 110x112 rect). + /// + [InstalledDatFact] + public void ColorSpotAndGradDiskResources_ResolveToExpectedDimensions() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + (uint enumId, int width, int height)[] expected = + [ + (0x1000000Du, 37, 44), // spot (active) + (0x1000000Fu, 37, 44), // blank (blocked) + (0x1000000Eu, 110, 112), // gradDisk + (0x10000010u, 110, 112), // gradPlug + ]; + foreach ((uint enumId, int width, int height) in expected) + { + uint did = RetailDataIdResolver.Resolve(dats, enumId, 7u); + Assert.NotEqual(0u, did); + Assert.True(dats.TryGet(did, out var rs) && rs is not null); + Assert.Equal(width, (int)rs!.Width); + Assert.Equal(height, (int)rs.Height); + } + + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + uint spotDid = RetailDataIdResolver.Resolve(dats, 0x1000000Du, 7u); + uint gradDiskDid = RetailDataIdResolver.Resolve(dats, 0x1000000Eu, 7u); + + // The nine pColor swatch elements (retail's DoColorSpots targets) + // author exactly ONE DirectState sprite — and it resolves to the + // SAME RenderSurface as the "spot" enum resource above, i.e. the + // button's own static authored art already IS the un-recolored + // (black-center) spot template. This is exactly why the OLD Tint- + // multiply mechanism visibly tinted the ring: it was multiplying + // this real authored sprite, not drawing over nothing. No Normal/ + // Highlight states exist (GF-9's own finding — the swatch's click + // feedback is a SEPARATE overlay element, not a state swap here). + ElementInfo spotElement = Assert.IsType(FindInfo(rootInfo, 0x1000030Fu)); + Assert.Equal(37f, spotElement.Width); + Assert.Equal(44f, spotElement.Height); + var spotDirectState = Assert.Single(spotElement.StateMedia); + Assert.Equal(string.Empty, spotDirectState.Key); + Assert.Equal(spotDid, spotDirectState.Value.File); + + // The grad circle likewise authors its own DirectState sprite — + // resolving to the SAME RenderSurface as the "gradDisk" enum + // resource — so the pre-fix Tint-multiply mechanism DID show + // something for the disc too (the un-recolored gradient wheel, + // multiplied); R3-6's actual gap was that Eyes needs a DIFFERENT + // source image (the plug icon) which no per-state authored data + // provides — SetSelection swaps it procedurally in retail, exactly + // what RuntimeImageTexture now reproduces. + ElementInfo gradCircleElement = Assert.IsType(FindInfo(rootInfo, 0x1000030Eu)); + Assert.Equal(110f, gradCircleElement.Width); + Assert.Equal(112f, gradCircleElement.Height); + var gradDirectState = Assert.Single(gradCircleElement.StateMedia); + Assert.Equal(string.Empty, gradDirectState.Key); + Assert.Equal(gradDiskDid, gradDirectState.Value.File); + } + + /// + /// R3-5: pixel-level ground truth for + /// 's + /// whole premise — the ACTIVE spot template (enum 0x1000000d) + /// has a genuinely near-black CENTER region and a genuinely non-black + /// RING/border region, and the BLANK template (enum 0x1000000f) + /// has almost no black pixels at all (it is a DIFFERENT piece of art, + /// not the spot with its center left un-recolored). + /// + [InstalledDatFact] + public void SpotTemplate_HasBlackCenterAndNonBlackRing_BlankTemplateHasNeitherBlack() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint spotDid = RetailDataIdResolver.Resolve(dats, 0x1000000Du, 7u); + uint blankDid = RetailDataIdResolver.Resolve(dats, 0x1000000Fu, 7u); + Assert.True(dats.TryGet(spotDid, out var spotRs) && spotRs is not null); + Assert.True(dats.TryGet(blankDid, out var blankRs) && blankRs is not null); + + var spot = AcDream.Core.Textures.SurfaceDecoder.DecodeRenderSurface(spotRs!); + var blank = AcDream.Core.Textures.SurfaceDecoder.DecodeRenderSurface(blankRs!); + + (int black, int nonBlackOpaque, int total) CountPixels(byte[] rgba) + { + int black = 0, nonBlackOpaque = 0, total = 0; + for (int i = 0; i + 3 < rgba.Length; i += 4) + { + byte a = rgba[i + 3]; + if (a < 10) continue; + total++; + if (rgba[i] == 0 && rgba[i + 1] == 0 && rgba[i + 2] == 0) black++; + else nonBlackOpaque++; + } + return (black, nonBlackOpaque, total); + } + + var spotCounts = CountPixels(spot.Rgba8); + var blankCounts = CountPixels(blank.Rgba8); + + // The spot genuinely has both a substantial black region (the + // center, to recolor) AND a substantial non-black region (the + // ring, to leave alone) — proves this isn't an all-black or + // all-colored template. + Assert.True(spotCounts.black > 100, $"expected a real black center, got {spotCounts.black} black pixels"); + Assert.True(spotCounts.nonBlackOpaque > 100, $"expected a real non-black ring, got {spotCounts.nonBlackOpaque}"); + + // The blank template is a DIFFERENT asset, not "spot with an + // un-recolored center" — near-zero black pixels. + Assert.True( + blankCounts.black < spotCounts.black / 10, + $"blank template has {blankCounts.black} black pixels, expected far fewer than the spot's {spotCounts.black}"); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); @@ -1603,4 +1892,32 @@ public sealed class CharacterCreationLiveDatTests null, new DatStringResolver(dats).Resolve); } + + /// + /// R3-4/R3-5c (re-test 2): the chargen shade slider's own thumb child + /// (0x10000321 > structural id 1) authors ONE DirectState + /// sprite of its own (0x06004D50) and ZERO children — the + /// EARLIER (Batch F11-era) investigation misread this as "genuinely + /// nothing authored here" from a string.Join display artifact (a + /// single "" DirectState key joins to an empty string, indistinguishable + /// from zero entries in a printed log — NOT a code defect, a + /// diagnostic-only mistake). This is the EXACT SAME single-sprite-thumb + /// shape 's R3-4/R3-7 fix + /// already handles (slices.Length == 0 -> ThumbSprite = + /// DefaultImage(thumb)) — no separate fix was needed for the shade + /// slider; this test proves the SAME code path already resolves it. + /// + [InstalledDatFact] + public void ShadeSlider_ThumbAuthorsItsOwnDirectStateSprite_BuildsWithNonZeroThumbSprite() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiScrollbar shadeSlider = Assert.IsType(screen.FindElement(0x10000321u)); + Assert.False(shadeSlider.Horizontal); + Assert.NotEqual(0u, shadeSlider.ThumbSprite); + } } From 28704db4bfba92be9f1c857a57853845465d3cec Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 18:08:13 +0200 Subject: [PATCH 135/138] docs: Campaign CC gate round 1 re-test 3 findings R4-1..R4-4 (lead's live captures) Co-Authored-By: Claude Fable 5 --- ...-08-16-campaign-cc-gate-round1-findings.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 734ed3e3..5b55fee0 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1005,3 +1005,26 @@ ISSUES.md; this doc is the six-page batch. Root-cause investigation FIRST (report-only, static + live-DAT probe tests, NO client launches while the user's client may be running), then batched fix rounds per family with Opus review, one republish per batch. + +## RE-TEST 3 (lead's own live captures of `1.0.2-cc.m`, 2026-08-16) — R4-1..R4-4 + +Captured by the lead driving the real client (testaccount, graceful +close). Heritage/Profession/Appearance/Town/Skills-selection/Summary all +render retail-shaped; four residuals visible in the captures: + +- **R4-1: Skills credits value overlaps the caption again** + ("Available Skill0Credits") — the re-test-2 wrap fix removed Batch E's + caption-width confinement without re-solving the overlap (its own + report flagged the risk). Root-fix the caption/value geometry from the + authored data (where does retail's value actually sit relative to the + caption on `0x100003F9`?). +- **R4-2: the scrollbar thumb TILES** — multiple diamond sprites stacked + down the track (Summary's overview bar shows ~9, Skills 2) instead of + ONE thumb at the scroll position. The re-test-2 single-sprite-thumb + fallback draws repeated/tiled sprites. +- **R4-3: the skills info-box formula line clips** at the box's bottom + edge (the four-line composition exceeds the authored interior). +- **R4-4: the Appearance help text starts mid-sentence** ("right arrows + next to the article of clothing…") — the opening paragraph is either + scrolled off (box has no visible scrollbar) or missing from the + composition; check what retail authors/composes for that box. From e6acb800cc67b0ac917ceb70dd47ae4e14e08ef3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 19:05:23 +0200 Subject: [PATCH 136/138] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20?= =?UTF-8?q?round=201=20re-test=203=20=E2=80=94=20R4-1..R4-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four visual residuals from the lead's own live-client captures of 1.0.2-cc.m, all root-caused via decomp + live-DAT evidence: - R4-1: Skills credits value overlapped mid-caption again. Root cause was a missing UiLayoutPolicy raw-edge reflow on UiButton's value-child rect (the child is base-inherited across four sibling buttons of differing widths, so its baked-in OriginalParentWidth diverges from the actual 231px-wide Skills credits button) plus an HJustify.Right value child mapped to Center instead of a real far-edge Right. - R4-2: the single-sprite scrollbar thumb tiled (GL_REPEAT) instead of drawing once — DrawTiled was reused for a small fixed marker graphic whose native size is far smaller than the track-proportional thumb rect. New DrawThumbMarker draws exactly one native-size instance. - R4-3: the skills info-box formula line clipped past the surrounding gold frame's own authored bottom edge (the pane's own raw box is 20px taller than the frame that visually contains it) — clamp the pane's Height to the frame's bottom (register AD-105, since retail's ShowSkillsText has no code relationship to the frame to cite). - R4-4: the Appearance help text started mid-sentence — the box was never touched by its page controller, so it kept UiText's chat-style PreserveEndOnLayout=true default; the scroll model's wasAtEnd check is vacuously true on its first-ever overflow transition, pinning the first render to the bottom. Set PreserveEndOnLayout=false (a static top-oriented report, not a transcript) and wired the box's own nested authored scrollbar, never wired before. App suite live-DAT env 5372/3 -> 5379/3 (+7, zero regressions). Runtime 1735/0 unchanged. Full solution 14585/4 skips/1 failure (the documented Core.Net NakEmission full-solution-only flake, confirmed standalone-pass). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- ...-08-16-campaign-cc-gate-round1-findings.md | 124 ++++++++++++++++++ .../Layout/CharacterCreationAppearancePage.cs | 54 ++++++++ .../UI/Layout/CharacterCreationSkillsPage.cs | 38 ++++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 77 ++++++++++- src/AcDream.App/UI/UiButton.cs | 28 +++- src/AcDream.App/UI/UiScrollbar.cs | 54 +++++++- .../Layout/CharacterCreationLiveDatTests.cs | 65 +++++++++ .../CharacterCreationUiControllerTests.cs | 90 +++++++++++++ .../UI/Layout/DatWidgetFactoryTests.cs | 100 ++++++++++++++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 57 ++++++++ 11 files changed, 678 insertions(+), 12 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 84980f5b..dbe19519 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 80 active rows (AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 81 active rows (AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -192,6 +192,7 @@ readiness/requeue adaptation. See | AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. **Campaign CC CC4 review-fix round R1 (2026-08-15): `FixedCanvasSize` now has a single arbiter.** Character-creation can be simultaneously active on top of character-management (both author the same 800x600 canvas), so a raw property write from either controller was a last-writer-wins race with no owner — chargen's own Close() nulled the canvas out from under a still-active character-management screen underneath it. `UiRoot.DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` now own every production write: each screen declares on its activation edge and revokes on close/deactivate/dispose; the effective size is the current declaration set's value (asserted equal across every concurrent declarer — a future mismatched screen throws instead of silently winning), and it nulls only once EVERY declarer has revoked. The raw `FixedCanvasSize` setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `DeclareFixedCanvas`, `RevokeFixedCanvas`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` and `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (both declare/revoke through the arbiter on activate/close/deactivate/dispose) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) DECLARES via `UiRoot.DeclareFixedCanvas` while active and REVOKES on close — per-screen opt-in through the arbiter, not automatic and not a raw write. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `CharacterScreensFixedCanvasArbiterTests` (the two-controller arbiter gate); `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | | 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-105 | **Filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 (skills info-box formula line clips at the frame's bottom edge).** `CharacterCreationSkillsPage`'s constructor clamps the description pane's (`0x100003fc`) live `Height` down to the bottom edge of the SIBLING gold decorative frame (`0x100003fa`, the SAME GF-12 corner/edge sprite family) whenever the frame's own authored bottom (Y=430 h=110 → 540, live-DAT-measured) sits ABOVE the pane's own raw bottom (Y=460 h=100 → 560) — a 20px overshoot that let a long skill's formula line draw into blank page space below the frame's visible border. Retail's own `ShowSkillsText @0x00481250` has NO code relationship between the two text panes and this frame (`UIElement_Text::SetText` only, no size/clip handoff) — the frame's authored geometry is used here as the only available ground truth for "the visible box," not a decomp-confirmed clip mechanism. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (constructor, the `InfoBoxFrameElementId` clamp block) | No decomp evidence describes HOW retail reconciles a text pane authored taller than its own decorative frame — this is the most defensible non-arbitrary boundary (an AUTHORED sibling rect, not an invented pixel offset) but is still an INFERENCE, not a confirmed retail mechanism. If retail instead resizes/repositions the frame to the pane, or genuinely allows the same 20px overshoot, this clamp diverges from the real behavior. | A future decomp/cdb capture of `gmCGSkillsPage`'s real screen layout, or a user visual re-check specifically of a 4-5-line skill description (e.g. skill id 52, Deception), could reveal the clamp boundary is wrong (too tight/too loose) — worst case the formula line is STILL cut, one pixel short of what retail shows, or clipped MORE than retail does. | `gmCGSkillsPage::ShowSkillsText @0x00481250` (no frame/size relationship in the decompiled body); live-DAT geometry (`0x100003fa` Y=430 H=110, `0x100003fc` Y=460 H=100) | | AD-104 | **Filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 (Skills info-box title/description overlap).** `CharacterCreationSkillsPage` force-sets `VerticalJustify = VJustify.Top` on the info-box title (`0x100003fb`) and description (`0x100003fc`) panes post-construction, compensating for a client-wide bug: neither element authors dat property `0x15`, and this port's shared unauthored-VJustify default (`ElementInfo.VJustify` field default `Center`, plus `ElementReader.cs`/`DatWidgetFactory.cs`'s import/build-time enum-mapping switches) resolves an absent `0x15` to Center — but retail's REAL ctor default (`UIElement_Text::UIElement_Text @0x004685ff`, `m_eVerticalJustification = 4`) resolves via `UIElement_Text::CalcJustification @0x00467260`'s actual enum table (`1=>Center, 3 or 5=>Bottom(far edge), else=>Top(near edge)`) to Top, not Center. The two panes' own AUTHORED boxes overlap by 75px (title Y=435 h=100, description Y=460 h=100, live-DAT-measured) — under the CORRECT Top default both render near their own box's top edge (25px apart) and no longer collide; under the port's current (wrong) Center default both cluster near the middle of their overlapping boxes and visually collide. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (constructor, post-`_infoTitle`/`_infoText` resolution) | The shared mapping bug (`ElementReader.cs:507`'s switch, `DatWidgetFactory.cs:704`'s switch, and `ElementInfo.VJustify`'s field default) is CLIENT-WIDE and affects every DAT-imported `UiText` reaching the `Centered`/`RightAligned`/`OneLine` static paths or the multi-line honored-justification path — including already-shipped, visually-verified, FROZEN surfaces (vitals numbers, chat, main game UI, Options panel) that may rely on the CURRENT Center default for their existing correct-looking alignment. A page-scoped override for exactly the two elements proven broken avoids a client-wide regression sweep this session has no budget for; the shared fix is filed as ISSUES.md #410 for its own dedicated investigation. | If ISSUES #410's shared fix ever lands, this page's override becomes redundant (harmless but should be removed in the same commit, since the corrected shared default would already resolve to Top). Until then, any OTHER DAT-imported `UiText` with an unauthored `0x15` that happens to sit close to a sibling text element (the same "two 100px-tall overlapping boxes" shape) can exhibit the same visual-collision symptom, undiscovered until its own gate round. | `UIElement_Text::UIElement_Text @0x004685ff` (ctor default = 4); `UIElement_Text::CalcJustification @0x00467260` (real enum semantics); ISSUES.md #410 | | 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-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)`) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 5b55fee0..be181039 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1028,3 +1028,127 @@ render retail-shaped; four residuals visible in the captures: next to the article of clothing…") — the opening paragraph is either scrolled off (box has no visible scrollbar) or missing from the composition; check what retail authors/composes for that box. + +**RE-TEST 3 fix batch (2026-08-16, R4-1..R4-4) is CODE-COMPLETE, pending the +user's visual gate.** All four findings root-caused and fixed via decomp + +live-DAT evidence, no invented pixel offsets. App suite live-DAT env +5372/3 → 5379/3 (+7, zero regressions); Runtime 1735/0 unchanged; full +solution 14585 tests / 4 skips / 1 failure (Core.Net +`NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge`, +the documented full-solution-only flake — confirmed passing standalone, +unrelated to this batch's files). No client launches. + +- **R4-1 FIXED — root cause was a MISSING raw-edge reflow, not a wrap/clip + gap.** Live-DAT probe: the Skills credits value child (`0x100002f3`) is + BASE-INHERITED across four sibling buttons of DIFFERING widths — + Health/Stamina/Mana at 150px share the exact same child id/rect (local + X=116) as the wider, 231px Skills credits button — and the child's own + `OriginalParentWidth` (150, baked in wherever it was first resolved, + matching Health's own actual width) diverges from Skills credits' real + 231px parent. `UiButton.ValueBox` was built from the child's RAW + (un-reflowed) rect, never running it through `UiLayoutPolicy` — the SAME + retail raw-edge system (`UIElement::UpdateForParentSizeChange + @0x00462640`) already used for every LIVE mounted `UiElement` via + `UiElement.ApplyAnchor`. The child's own edge modes (Left=2/Right=1, + live-DAT-confirmed "track the far edge as the parent grows") shift the + value box from X=116 to X=197 for Skills credits specifically — landing + immediately after the caption's own measured 193px span (ends ≈x=196) + instead of colliding mid-caption. Separately, `ValueAlign` mapped + `HJustify.Right` (raw dat 3/5, live-DAT-confirmed authored on ALL four + value children) to Center — `UIElement_Text::CalcJustification + @0x00467260`'s own `ecx_5==3||5` branch is a DISTINCT far-edge formula, + not Center's halved offset; added a `LabelAlignment.Right` case. + Health/Stamina/Mana (whose `OriginalParentWidth` already matches their + own actual width) reflow to their byte-identical raw rect — the fix is + additive, not a per-button special case. + Files: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildButton`'s + value-child block, new `ReflowValueChildRect`), `src/AcDream.App/UI/UiButton.cs` + (`LabelAlignment.Right`, `OnDraw`'s value-draw `vx` switch). Tests: + `DatWidgetFactoryTests.BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton` + (+ its `..._OriginalParentMatchesActual_RectUnchanged` negative + companion); live-DAT + `CharacterCreationLiveDatTests.SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged` + (pins the real installed DAT's `197,0,34,28` vs `116,0,34,28`). +- **R4-2 FIXED — the single-sprite-thumb fallback was TILING (UV-repeat) + a small marker graphic instead of drawing it once.** The re-test-2 fix + (R3-4/R3-7) correctly identified the thumb sprite but fed it to + `DrawTiled` (GL_REPEAT UV wrap) — for a small fixed "diamond" marker + drawn into a track-proportional thumb rect far taller than its own + native size (`UIElement_Scrollbar::UpdateLayout @0x4710d0`'s + `max(MinThumb, trackLen*ThumbRatio)` formula, unchanged/still correct + for the rect's SIZE), the texture sampler repeated the marker several + times down the track (~9 on Summary's overview bar, ~2 on Skills, + matching the live capture). New `DrawThumbMarker` draws exactly ONE + instance at the sprite's own native size, centered within the SAME + computed rect — neither tiled (the bug) nor stretched into an elongated + bar (a naive `DrawSprite` fix would have distorted the diamond shape). + The shade slider's own scalar-mode draw path (`DrawVerticalScalar`) was + never touched — it already used the correct native-size `DrawSprite` + pattern this fix now mirrors for model-mode bars. + File: `src/AcDream.App/UI/UiScrollbar.cs` (`DrawVerticalModel`/ + `DrawHorizontalModel`'s fallback branch, new `DrawThumbMarker`). Test: + `UiScrollbarTests.SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack` + — reads back the actual emitted quad's UV V-coordinate via + `TextRenderer.DebugSpriteSegmentVerts` and asserts it never exceeds 1.0 + (native); confirmed this test FAILS (V=7.875) against the pre-fix + `DrawTiled` call by temporarily reverting and re-running. +- **R4-3 FIXED — the description pane's own authored box is genuinely + taller than the decorative frame that visually contains it.** Live-DAT + geometry walk: the gold frame (`0x100003fa`, the SAME GF-12 corner/edge + sprite family as the Appearance help box) spans Y=430 H=110 (bottom + Y=540), but the description pane (`0x100003fc`) spans Y=460 H=100 + (bottom Y=560) — 20px PAST the frame's own bottom border. Composition- + height simulation against every one of the 38 skills carrying detail + data (real `ChargenTableReader` descriptions + the worst-case + description+bonus+formula line count) confirmed the pane's OWN raw + 100px interior comfortably fits every case (worst: 5 lines / 80px < 90px + interior) — ruling out a wrap-width or line-spacing bug. The real + mismatch is the SIBLING frame's smaller authored bottom, which the pane + was never clamped to, letting a tall composition's last line(s) draw + past the frame's own visible border into blank page space. Retail's + `ShowSkillsText @0x00481250` has no code linking the panes to the frame + (plain `SetText`, no size/clip handoff) — the frame's own authored Y+H + is the only available ground truth, not a decomp-confirmed clip + mechanism, so this is filed as register **AD-105** (a genuine + inference, flagged rather than silently assumed, same shape as R3-3's + own AD-104 scoped correction). `CharacterCreationSkillsPage`'s + constructor now clamps `_infoText.Height` to the frame's bottom edge + whenever it would otherwise be taller (additive; never grows it). + File: `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` + (constructor, new `InfoBoxFrameElementId` clamp block). Tests: fixture + `CharacterCreationUiControllerTests.SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom` + (the shared `BuildSkillsPage` fixture gained a deliberately-shorter + frame element); live-DAT + `CharacterCreationLiveDatTests.SkillsInfoBoxFrame_ShorterThanDescriptionPane` + (pins the 20px real-DAT mismatch itself). +- **R4-4 FIXED — two stacked gaps, the same "page never touched this + element" shape as prior holdouts.** The help box (`0x100003ab`) is a + purely DAT-authored static paragraph (no `gmCGAppearancePage` runtime + composition function exists for it, unlike Town/Summary's + `SetTownString`/`SetHowToText` — confirmed absent from the named + decomp) that `CharacterCreationAppearancePage` never referenced at all, + so it kept `UiText`'s own chat-style default + (`PreserveEndOnLayout=true`, "keep a view that is already at the end + pinned there"). Its content overflows a 292px-tall frame, and + `UiScrollable.SetExtents`'s own `wasAtEnd` check is vacuously true the + FIRST time a Scroll model transitions from its zero-initialized state + (`ContentHeight=0/ViewHeight=0/ScrollY=0` → `MaxScroll=0` → + `AtEnd=(0>=0)=true`) to real overflowing content — with + `PreserveEndOnLayout` still true, that spuriously pins the very first + render to the BOTTOM, hiding the opening paragraphs exactly as reported + (the visible text is mid-way through the third paragraph). This is a + static instructions box, not a chat transcript — `PreserveEndOnLayout`'s + own doc already carves out exactly this shape ("top-oriented reports + such as Character Information disable it"). Also wired the box's own + NESTED authored scrollbar (property `0x72`, live-DAT-confirmed a direct + Type-11 child of the text box — the SAME nesting shape + `CharacterCreationSummaryPage.HowToScrollRelativeId` already uses) — + never wired by this page before, so a user can reach the rest of the + text even where the box's own height still doesn't fit everything. + File: `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` + (constructor, new `HelpTextId`/`HelpScrollRelativeId` block). Tests: + fixture + `CharacterCreationUiControllerTests.AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired` + (the shared `BuildAppearancePage` fixture gained the help box + its + nested scrollbar child, StateMedia-bearing so `UiText`'s own dat- + children carve-out actually builds it). diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index e98e81e2..7929129b 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -144,6 +144,26 @@ internal sealed class CharacterCreationAppearancePage : IDisposable internal const uint ShadeScrollId = 0x10000321u; internal const uint ViewportId = 0x100003BBu; + /// + /// R4-4 (Campaign CC gate round 1 re-test 3): the framed instructions + /// box (the SAME gold corner/edge sprite family the Skills info-box + /// frame uses, 0x100002de-e3/0x100000e8/0xea — + /// live-DAT-confirmed identical children). Its own P0x17 caption + /// is the FULL static help paragraph (no gmCGAppearancePage + /// runtime composition exists for it — unlike Town/Summary's + /// SetTownString/SetHowToText, this text is purely + /// DAT-authored, confirmed by the absence of any matching function in + /// the named decomp). + /// + internal const uint HelpTextId = 0x100003ABu; + + /// The help box's own NESTED scrollbar child (live-DAT- + /// confirmed a direct child of , the SAME + /// structural id/nesting shape as + /// CharacterCreationSummaryPage.HowToScrollRelativeId's own + /// how-to box scrollbar). + private const uint HelpScrollRelativeId = 0x100002E7u; + /// Retail's nine SetColor(0..8) swatch buttons, in /// index order — verbatim off ListenToElementMessage's cases /// 5-0xd (elementId - 0x1000030a). @@ -202,6 +222,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiButton? _rotateCounterClockwise; private readonly UiButton? _zoomIn; private readonly UiButton? _zoomOut; + private readonly UiText? _helpText; /// The gradient disc (0x1000030e) — Type 3 in the /// authored dat, so (not the base @@ -354,6 +375,39 @@ internal sealed class CharacterCreationAppearancePage : IDisposable _zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal); }; + // R4-4 (Campaign CC gate round 1 re-test 3): the help box's static + // paragraph starts mid-sentence because this port never touched + // this element at all — it built through the plain DatWidgetFactory + // import path with UiText's own chat-style default + // (PreserveEndOnLayout=true, "keep a view that is already at the + // end pinned there" — see that property's own doc: "Chat uses the + // default; top-oriented reports such as Character Information + // disable it"). This box's content overflows its own view (a full + // multi-paragraph instructions block in a 292px-tall frame), and + // UiScrollable.SetExtents's own wasAtEnd check is vacuously true + // the very first time a Scroll model transitions from its + // zero-initialized state (ContentHeight=0/ViewHeight=0/ScrollY=0 -> + // MaxScroll=0 -> AtEnd=(0>=0)=true) to real overflowing content — + // with PreserveEndOnLayout still true, that spuriously pins the + // FIRST-EVER render to the bottom, hiding the opening paragraph + // exactly as reported ("right arrows next to the article of + // clothing..." is mid-way through the third paragraph, not the + // first). This is a static instructions box, not a chat transcript + // — the SAME top-oriented-report shape PreserveEndOnLayout's own + // doc already carves out. Also wires the box's own nested authored + // scrollbar (property 0x72, live-DAT-confirmed a direct child) — + // NEVER wired by this page before — so a user can still reach the + // rest of the text if it doesn't fully fit, the SAME + // scrollbar.Model = text.Scroll linkage + // CharacterCreationSummaryPage's how-to box already uses. + _helpText = Find(pageRoot, HelpTextId); + if (_helpText is not null) + { + _helpText.PreserveEndOnLayout = false; + if (Find(_helpText, HelpScrollRelativeId) is { } helpScroll) + helpScroll.Model = _helpText.Scroll; + } + ApplyChoiceVisibility(); } diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index b8264fcb..40bac999 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -341,8 +341,46 @@ internal sealed class CharacterCreationSkillsPage : IDisposable infoTitle.VerticalJustify = VJustify.Top; if (_infoText is { } infoText) infoText.VerticalJustify = VJustify.Top; + + // R4-3 (Campaign CC gate round 1 re-test 3): the description pane's + // own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT- + // measured) extends 20px PAST the bottom of the gold decorative + // frame that visually contains BOTH info panes (0x100003fa, Y=430 + // H=110 -> bottom Y=540, the SAME GF-12 corner/edge sprite family + // Batch C un-consumed — 0x100002de-e3/0x100000e8/0xea). Retail's own + // ShowSkillsText @0x00481250 has NO code relationship between the + // text panes and this frame (SetText only; no clip/size handoff), + // and the frame's 8 children carry no dat property linking them to + // 0x100003fc either — so the frame's own geometry is the only + // authored ground truth for "the visible box," and this port's + // multi-line clip (UiText.DrawText's own PushClip(0,0,Width,Height)) + // was using the WRONG (larger, unbounded) Height, letting a long + // skill's formula line draw into blank page space below the frame's + // own border instead of being contained by it. Clamped to the + // frame's own bottom edge (never grows it — additive, defensive if a + // future dat re-extract makes the frame taller than the pane). + // Scoped exactly like the VJustify.Top correction above: this is + // NOT the client-wide "does a text pane's clip account for a + // sibling decorative frame" mechanism (no evidence any other pane in + // this codebase has the SAME independently-authored-taller-than-its- + // frame shape), so a general import-time fix is unwarranted here. + if (_infoText is { } clampedInfoText + && UiElement.FindDescendant(pageRoot, InfoBoxFrameElementId) is { } frame) + { + float frameBottom = frame.Top + frame.Height; + float paneBottom = clampedInfoText.Top + clampedInfoText.Height; + if (frameBottom < paneBottom) + clampedInfoText.Height = frameBottom - clampedInfoText.Top; + } } + /// + /// The gold decorative frame (Type 12, 8 sprite children — the SAME + /// GF-12 corner/edge family) that visually contains BOTH info panes + /// (0x100003fb/0x100003fc) — see the R4-3 clamp above. + /// + private const uint InfoBoxFrameElementId = 0x100003FAu; + internal void Refresh( IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 86b037e4..c2f939fd 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -1009,14 +1009,29 @@ public static class DatWidgetFactory child => child.Type == 12u && child.StateMedia.Count == 0); if (valueChild is not null) { - button.ValueBox = (valueChild.X, valueChild.Y, valueChild.Width, valueChild.Height); + // R4-1 (Campaign CC gate round 1 re-test 3): reflow the value + // child's authored rect through retail's own raw-edge policy + // (UIElement::UpdateForParentSizeChange @0x00462640, ported + // as UiLayoutPolicy) before it becomes ValueBox — see + // ReflowValueChildRect's own doc for why this is needed and + // decomp-cited. + button.ValueBox = ReflowValueChildRect(valueChild, info); button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null ? fontResolve(valueChild.FontDid) ?? elementFont : elementFont; button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One; - button.ValueAlign = valueChild.HJustify == HJustify.Left - ? UiButton.LabelAlignment.Left - : UiButton.LabelAlignment.Center; + button.ValueAlign = valueChild.HJustify switch + { + HJustify.Left => UiButton.LabelAlignment.Left, + // R4-1: HJustify.Right (raw dat 3/5) previously fell into + // this ternary's Center branch — CalcJustification's own + // ecx_5==3||5 case is a DISTINCT far-edge formula (see + // UiButton.LabelAlignment.Right's own doc), and every + // value child in this family (0x100002f1/0x100002f3) + // authors HJustify Right, live-DAT-confirmed. + HJustify.Right => UiButton.LabelAlignment.Right, + _ => UiButton.LabelAlignment.Center, + }; // Seed with whatever the child itself authors (typically // blank) so an unbound button doesn't draw stray leftover // text before a controller writes a real value. @@ -1027,6 +1042,60 @@ public static class DatWidgetFactory return button; } + /// + /// R4-1 (Campaign CC gate round 1 re-test 3): the "Available Skill + /// Credits" value overlapped mid-caption ("Available Skill0Credits") + /// because was built from the value + /// child's RAW authored rect, un-reflowed. Live-DAT probe: the value + /// child (0x100002f3) is BASE-INHERITED across four sibling + /// buttons of DIFFERING widths — Health/Stamina/Mana at 150px share the + /// exact same child id/rect (local X=116) as the wider, 231px Skills + /// credits button, and the child's own OriginalParentWidth (the + /// design-time parent size baked in at whichever button FIRST resolved + /// it — 150, matching Health's own actual width) diverges from Skills + /// credits' actual current parent width (231) — exactly the shape + /// (retail + /// UIElement::UpdateForParentSizeChange @0x00462640, already the + /// production raw-edge reflow for live mounted elements via + /// ) exists to correct. The child's + /// own edge modes (Left=2/Right=1, live-DAT-confirmed) are retail's + /// "track the far edge as the parent grows" reflow: applying them moves + /// the value box from local X=116 to X=197 for Skills credits — landing + /// immediately after the caption's own measured end (~x=196, + /// SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint) + /// instead of colliding mid-caption. Health/Stamina/Mana and the + /// Attribute/Credits value child (whose OWN OriginalParentWidth already + /// matches their actual parent, or whose edge modes are all 0/fixed) + /// reflow to their byte-identical raw rect (deltaX=0 or mode-0 passthrough) + /// — this is additive for every already-correct button, not a per-button + /// special case. + /// + private static (float X, float Y, float Width, float Height) ReflowValueChildRect( + ElementInfo child, ElementInfo parent) + { + float originalParentWidth = child.HasOriginalParentSize ? child.OriginalParentWidth : parent.Width; + float originalParentHeight = child.HasOriginalParentSize ? child.OriginalParentHeight : parent.Height; + + var originalChild = UiPixelRect.FromPositionAndSize( + (int)child.X, (int)child.Y, (int)child.Width, (int)child.Height); + var originalParent = UiPixelRect.FromPositionAndSize( + 0, 0, (int)originalParentWidth, (int)originalParentHeight); + var currentParent = UiPixelRect.FromPositionAndSize( + 0, 0, (int)parent.Width, (int)parent.Height); + // Empty (Width=0/Height=0) "current child" so the static Apply's + // currentChild-preservation branch never engages — every axis comes + // from the Near/Far formula, matching mode 0's own "keep the raw + // authored edge" default for the (frequent) no-anchor case. + var noCurrentChild = new UiPixelRect(0, 0, -1, -1); + + UiPixelRect reflowed = UiLayoutPolicy.Apply( + child.Left, child.Top, child.Right, child.Bottom, + originalChild, originalParent, + noCurrentChild, currentParent); + + return (reflowed.X0, reflowed.Y0, reflowed.Width, reflowed.Height); + } + /// /// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its /// authored indicator child. Its label lives on the option object rather than diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 9b2dd988..45166aba 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -260,8 +260,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// — the lifted child's own authored justify. public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center; - /// Label horizontal alignment options. - public enum LabelAlignment { Center, Left } + /// + /// Label horizontal alignment options. (R4-1, Campaign + /// CC gate round 1 re-test 3) is ValueLabel-only today — every value + /// child on the chargen credit-display family (0x100002f1/0x100002f3) + /// authors dat HJustify Right (raw 3/5), decomp-confirmed by + /// UIElement_Text::CalcJustification @0x00467260's + /// ecx_5==3||5 branch (edi = availWidth - textWidth, i.e. + /// flush to the box's own far edge) — distinct from Center's halved + /// offset. never authors Right today so no + /// existing switch over it needs a new arm. + /// + public enum LabelAlignment { Center, Left, Right } public bool ToggleBehavior { get; } public bool RolloverEnabled { get; } @@ -568,9 +578,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful float boxY = ValueBox?.Y ?? 0f; float boxWidth = ValueBox?.Width ?? Width; float boxHeight = ValueBox?.Height ?? Height; - float vx = ValueAlign == LabelAlignment.Left - ? boxX + LabelOffsetX - : boxX + (boxWidth - vf.MeasureWidth(value)) * 0.5f; + float valueWidth = vf.MeasureWidth(value); + // R4-1: Right mirrors CalcJustification's own far-edge formula + // (box's own right edge minus the measured text width, no + // decorative inset — the decomp's Right branch adds none either, + // and this box carries no threaded marginR of its own). + float vx = ValueAlign switch + { + LabelAlignment.Left => boxX + LabelOffsetX, + LabelAlignment.Right => boxX + boxWidth - valueWidth, + _ => boxX + (boxWidth - valueWidth) * 0.5f, + }; float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f; ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor); } diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 7a798139..3892bdac 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -286,11 +286,60 @@ public sealed class UiScrollbar : UiElement } else { - DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + // R4-2 (Campaign CC gate round 1 re-test 3): the single- + // sprite thumb shape (no top/bottom caps — see this method's + // own doc, the R3-4/R3-7 fallback family: Skills listbox + // 0x100003f8, Summary overview 0x10000401, Summary how-to + // 0x100002e7) is a small fixed "diamond" marker graphic, NOT + // a stretchy bar — DrawTiled's UV-repeat was drawing it + // MULTIPLE times to fill the track-proportional thumb rect + // (~9 repeats on Summary's overview bar, ~2 on Skills, per + // the live capture). DrawThumbMarker draws exactly ONE + // instance at its own native size. + DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true); } } } + /// + /// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a + /// single-sprite scrollbar thumb at its own native size, centered + /// within the computed thumb rect ('s own + /// decomp-cited UIElement_Scrollbar::UpdateLayout @0x4710d0 + /// track-proportional geometry stays unchanged — this only changes HOW + /// the sprite fills that rect). Neither (UV- + /// repeat — draws the small marker graphic several times to fill a + /// large proportional thumb rect, R4-2's own "tiled diamonds" report) + /// nor a naive 1:1 stretch across the full computed rect (would distort + /// a small marker into an elongated bar) is correct for this shape — + /// selects which + /// axis is being filled/centered: a vertical scrollbar's thumb rect + /// varies in height (X/Width stay the bar's own full width, matching + /// every other draw call in this class), a horizontal one varies in + /// width (Y/Height stay the bar's own full height). + /// + private void DrawThumbMarker( + UiRenderContext ctx, Func resolve, + uint id, float rectX, float rectY, float rectW, float rectH, bool vertical) + { + if (id == 0 || rectW <= 0f || rectH <= 0f) return; + var (tex, nativeW, nativeH) = resolve(id); + if (tex == 0 || nativeW == 0 || nativeH == 0) return; + + if (vertical) + { + float drawH = MathF.Min(nativeH, rectH); + float y = rectY + (rectH - drawH) * 0.5f; + ctx.DrawSprite(tex, rectX, y, rectW, drawH, 0f, 0f, rectW / nativeW, drawH / nativeH, Vector4.One); + } + else + { + float drawW = MathF.Min(nativeW, rectW); + float x = rectX + (rectW - drawW) * 0.5f; + ctx.DrawSprite(tex, x, rectY, drawW, rectH, 0f, 0f, drawW / nativeW, rectH / nativeH, Vector4.One); + } + } + private void DrawHorizontalModel( UiRenderContext ctx, Func resolve, @@ -315,7 +364,8 @@ public sealed class UiScrollbar : UiElement } else { - DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height); + // R4-2: horizontal counterpart of the vertical fallback above. + DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 0cab5bd2..7dc5b92a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1673,6 +1673,36 @@ public sealed class CharacterCreationLiveDatTests Assert.Equal(116f, valueChild.X); } + /// + /// R4-1 (re-test 3): the raw authored value-child X (116, pinned above) + /// is NOT where the value actually draws — UiLayoutPolicy's + /// raw-edge reflow (the value child's own Right-tracking edge modes + /// against its base-inherited 150px OriginalParentWidth vs the + /// Skills-credits button's actual 231px width) shifts it to X=197, + /// landing right after the caption's own measured 193px span instead + /// of colliding mid-caption ("Available Skill0Credits"). Health's own + /// value child shares the SAME 150px OriginalParentWidth as its OWN + /// actual 150px-wide button (no divergence), so it reflows to its + /// byte-identical raw rect — proving the fix is additive, not a + /// blanket shift. + /// + [InstalledDatFact] + public void SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiButton skillsCredits = Assert.IsType(screen.FindElement(0x100003F9u)); + Assert.Equal((197f, 0f, 34f, 28f), skillsCredits.ValueBox); + Assert.Equal(UiButton.LabelAlignment.Right, skillsCredits.ValueAlign); + + UiButton health = Assert.IsType(screen.FindElement(0x100003E3u)); + Assert.Equal((116f, 0f, 34f, 28f), health.ValueBox); + } + /// /// R3-3 (re-test 2): the info-box title (0x100003fb) and /// description (0x100003fc) panes' own AUTHORED boxes overlap — @@ -1714,6 +1744,41 @@ public sealed class CharacterCreationLiveDatTests Assert.True(description.Y > title.Y); } + /// + /// R4-3 (re-test 3): the description pane's own raw box (Y=460, + /// H=100 -> bottom Y=560) extends PAST the bottom of the gold + /// decorative frame that visually contains both info panes + /// (0x100003fa, Y=430 H=110 -> bottom Y=540 — the SAME + /// corner/edge sprite family GF-12 already renders, + /// 0x100002de-e3/0x100000e8/0xea). Pins the + /// geometric mismatch itself (so a future DAT re-extract that removes + /// it is visible) — CharacterCreationSkillsPageTests' own fixture + /// covers the constructor's Height-clamp behavior against this exact + /// shape. + /// + [InstalledDatFact] + public void SkillsInfoBoxFrame_ShorterThanDescriptionPane() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + + ElementInfo frame = Assert.IsType(FindInfo(rootInfo, 0x100003FAu)); + ElementInfo description = Assert.IsType(FindInfo(rootInfo, 0x100003FCu)); + + float frameBottom = frame.Y + frame.Height; + float paneBottom = description.Y + description.Height; + Assert.True( + frameBottom < paneBottom, + $"expected the frame's own bottom (Y={frame.Y} H={frame.Height}, bottom={frameBottom}) to sit " + + $"ABOVE the description pane's own raw bottom (Y={description.Y} H={description.Height}, " + + $"bottom={paneBottom}) — if it no longer does, CharacterCreationSkillsPage's Height clamp " + + "may no longer be needed"); + } + /// /// R3-4/R3-7 (re-test 2): retail authors TWO distinct /// UIElement_Scrollbar thumb shapes. Chat's own scrollbar diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 72574340..30de665e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -457,6 +457,37 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(VJustify.Top, environment.SkillInfoText().VerticalJustify); } + /// + /// R4-3 (Campaign CC gate round 1 re-test 3): the description pane's + /// own raw box (0x100003fc) is TALLER than the surrounding gold + /// decorative frame that visually contains it (0x100003fa — + /// live-DAT-measured, see 's + /// own R4-3 comment for the full geometry + decomp citation: retail's + /// ShowSkillsText has no code relationship between the text + /// panes and this frame, so the frame's own authored bottom edge is + /// the only ground truth for "the visible box"). Before this fix, a + /// long skill's formula line could draw into blank page space below + /// the frame's own border — BuildSkillsPage's fixture frame + /// (Y=0 H=50) is deliberately shorter than TextInfo's own + /// default pane Height (60), so this pins the constructor clamping the + /// live-mounted pane's own Height down to the frame's bottom edge + /// (50) instead of leaving it at its own larger raw 60. + /// + [Fact] + public void SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + + Assert.Equal(50f, environment.SkillInfoText().Height, 3f); + // The title pane sits OUTSIDE the frame's own child range in this + // fixture (a sibling, not touched by the clamp) — confirms the fix + // is scoped to the description pane only, matching the constructor. + Assert.Equal(60f, environment.SkillInfoTitle().Height, 3f); + } + /// R2-4a: retail re-selects the row after an arrow click too /// (ListenToElementMessage @0x004814c0's own /// SetSelectedItem(...,1) call following @@ -1229,6 +1260,34 @@ public sealed class CharacterCreationUiControllerTests environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); } + /// + /// R4-4 (Campaign CC gate round 1 re-test 3): the framed help/ + /// instructions box (0x100003ab) — before this fix, this + /// element was never touched by 's + /// constructor at all, so it kept 's own chat-style + /// default (PreserveEndOnLayout=true) and its own nested + /// authored scrollbar (live-DAT-confirmed a direct Type-11 child, + /// property 0x72) was never wired to + /// . Pins both halves of the fix: the box is + /// no longer chat-style bottom-pinned, and the scrollbar's + /// now points at the SAME + /// the text itself scrolls. + /// + [Fact] + public void AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + UiText helpText = Assert.IsType( + environment.Screen.FindElement(CharacterCreationAppearancePage.HelpTextId)); + Assert.False(helpText.PreserveEndOnLayout); + + UiScrollbar helpScroll = Assert.IsType( + UiElement.FindDescendant(helpText, 0x100002E7u)); + Assert.Same(helpText.Scroll, helpScroll.Model); + } + /// /// GF-10 (Campaign CC gate round 1 Batch B): ports /// gmCGAppearancePage::ZoomIn @0x0047CF00 @@ -2813,6 +2872,21 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ScrollbarInfo(0x100003F8u)); page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge page.Children.Add(TextInfo(0x100003FBu)); + // R4-3 (re-test 3): the description pane's own decorative frame + // (0x100003fa, live-DAT-measured SHORTER than the pane it visually + // contains — see CharacterCreationSkillsPage's own R4-3 comment). + // Y=0/Height=50 here is deliberately shorter than TextInfo's own + // default Height=60 so CharacterCreationSkillsPageTests can pin the + // constructor's Height clamp without needing the real installed + // DAT's exact pixel geometry. + page.Children.Add(new ElementInfo + { + Id = 0x100003FAu, + Type = 12u, + Y = 0f, + Width = 200f, + Height = 50f, + }); page.Children.Add(TextInfo(0x100003FCu)); return page; } @@ -2912,6 +2986,22 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId)); page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); + // R4-4 (re-test 3): the framed instructions box, with its OWN + // nested authored scrollbar child — live-DAT-confirmed shape (a + // direct Type-11 child of the Type-12 text box, the SAME nesting + // CharacterCreationSummaryPage's HowToScrollRelativeId already + // uses). Deliberately taller than one view's worth so the long + // static help paragraph genuinely overflows in the test below. + var helpText = TextInfo(CharacterCreationAppearancePage.HelpTextId); + var helpScrollInfo = new ElementInfo { Id = 0x100002E7u, Type = 11u, Width = 12f, Height = 40f }; + // UiText/UiField's own dat-children carve-out (LayoutImporter.BuildWidget) + // only builds a child that carries its own authored StateMedia — the + // SAME "genuinely renderable chrome, not swallowed prototype data" + // gate the real scrollbar's own DirectState track sprite satisfies. + helpScrollInfo.StateMedia[""] = (0x06001919u, 1); + helpText.Children.Add(helpScrollInfo); + page.Children.Add(helpText); + return page; } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index f6d78b60..e218d444 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -499,6 +499,106 @@ public class DatWidgetFactoryTests Assert.Equal("42", button.ValueLabel); } + /// + /// R4-1 (Campaign CC gate round 1 re-test 3): the value child + /// (0x100002f3) is BASE-INHERITED across four sibling buttons of + /// DIFFERING widths — Health/Stamina/Mana at 150px share the same + /// child id/rect (local X=116) as the wider, 231px Skills credits + /// button — so the child's own authored OriginalParentWidth + /// (baked in at whichever button FIRST resolved it, 150) diverges from + /// the ACTUAL containing button's current width (231) for exactly the + /// wider button. Before this fix, ValueBox was the child's raw + /// (un-reflowed) rect regardless — this fixture reproduces that exact + /// shape (a 150px "design" width baked into the child, hosted under a + /// 231px-wide button, with the child's own Right-tracking edge modes + /// 2/1) and proves UiLayoutPolicy's raw-edge reflow now shifts + /// the value box by the SAME 81px the button grew (116 -> 197), + /// landing clear of "Available Skill Credits"'s own measured span + /// instead of colliding mid-caption ("Available Skill0Credits"). + /// + [Fact] + public void BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton() + { + uint captionStringId = 333u; + var info = new ElementInfo { Type = 1, Width = 231, Height = 28 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0), + }; + + var valueChild = new ElementInfo + { + Type = 12, + X = 116, + Y = 0, + Width = 34, + Height = 28, + Left = 2, + Top = 1, + Right = 1, + Bottom = 1, + OriginalParentWidth = 150, + OriginalParentHeight = 28, + HasOriginalParentSize = true, + HJustify = HJustify.Right, + }; + info.Children.Add(valueChild); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null)); + + Assert.Equal("Available Skill Credits", button.Label); + // 116 + (231-150) = 197 -> width/height preserved (34/28). + Assert.Equal((197f, 0f, 34f, 28f), button.ValueBox); + Assert.Equal(UiButton.LabelAlignment.Right, button.ValueAlign); + } + + /// + /// Negative companion to the reflow test above: a value child whose + /// OWN authored parent width already MATCHES the actual button (the + /// Health/Stamina/Mana shape, un-widened) reflows to its byte-identical + /// raw rect — delta is zero, so this is confirmed additive, not a + /// blanket shift. + /// + [Fact] + public void BuildButton_ValueChildOriginalParentMatchesActual_RectUnchanged() + { + uint captionStringId = 334u; + var info = new ElementInfo { Type = 1, Width = 150, Height = 28 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0), + }; + + var valueChild = new ElementInfo + { + Type = 12, + X = 116, + Y = 0, + Width = 34, + Height = 28, + Left = 2, + Top = 1, + Right = 1, + Bottom = 1, + OriginalParentWidth = 150, + OriginalParentHeight = 28, + HasOriginalParentSize = true, + }; + info.Children.Add(valueChild); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == captionStringId ? "Health" : null)); + + Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox); + } + /// /// Negative companion: a button whose caption was LIFTED from a /// distinct Type-12 child (the town-marker shape, diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 7a1b0441..36c80c83 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -1,3 +1,8 @@ +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using Xunit; @@ -466,4 +471,56 @@ public class UiScrollbarTests Assert.Equal(expectedWidth, width, 3); } + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + /// + /// R4-2 (Campaign CC gate round 1 re-test 3): the re-test-2 single- + /// sprite-thumb fallback (R3-4/R3-7, 's + /// no-cap-sprites branch) used to call the same UV-repeat + /// DrawTiled the 3-slice middle tile uses — for a small fixed + /// "diamond" marker sprite drawn into a MUCH taller track-proportional + /// thumb rect, GL_REPEAT wrapping visibly tiled the marker several + /// times down the track (Summary's overview bar ~9, Skills 2, per the + /// live capture). Proves the fix draws exactly ONE quad for the thumb + /// texture whose V range never exceeds native (1.0) — i.e. one + /// unstretched, untiled sprite instance — even though the computed + /// thumb rect (168px trackLen * ThumbRatio 0.75 = 126px, well past the + /// sprite's native 16px) is far taller than the sprite. + /// + [Fact] + public void SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + const uint thumbTex = 42u; + var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150 }; + var bar = new UiScrollbar + { + Width = 16f, + Height = 200f, + SpriteResolve = id => id == thumbTex ? (thumbTex, 16, 16) : (0u, 0, 0), + ThumbSprite = thumbTex, + // ThumbTopSprite/ThumbBotSprite stay unset -> the R3-4/R3-7 + // single-sprite fallback shape (no 3-slice caps authored). + Model = model, + }; + + bar.DrawSelfAndChildren(ctx); + + var thumbSegments = renderer.DebugSpriteSegmentVerts + .Where(s => s.Texture == thumbTex) + .ToArray(); + Assert.Single(thumbSegments); + var verts = thumbSegments[0].Verts; + // 8 floats/vertex (x,y,u,v,r,g,b,a), one quad = 6 vertices. + Assert.Equal(6, verts.Count / 8); + for (int i = 0; i < verts.Count; i += 8) + Assert.True(verts[i + 3] <= 1.0001f, $"thumb sprite V={verts[i + 3]} exceeds native (tiled)"); + } } From ff8b1ebc8974b52f1937790af2533eac17df5342 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 19:13:36 +0200 Subject: [PATCH 137/138] =?UTF-8?q?docs:=20Campaign=20CC=20connected=20gat?= =?UTF-8?q?e=20PASSED=20=E2=80=94=20campaign=20CLOSED=20user-accepted=2020?= =?UTF-8?q?26-08-16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extended gate round (GF-1..16, R2/R3/R4 re-tests, fix batches A-G + closeout + two re-test rounds) closed with the user's pass on build 1.0.2-cc.o. Plan status and ledger flipped; findings doc carries the full round history. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-15-character-creation-campaign.md | 11 ++++++++--- .../2026-08-16-campaign-cc-gate-round1-findings.md | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 8ac616e9..85bd2a60 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -1,7 +1,12 @@ # Campaign CC — retail character creation -**Status:** All seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is -CODE-COMPLETE pending the user's own connected gate. CC7 (the final slice) +**Status: CLOSED — USER-ACCEPTED 2026-08-16.** All seven slices (CC1-CC7) +REVIEW-CLOSED; the connected gate ran as one extended round (findings +GF-1..16, re-tests R2-1..8 / R3-1..9 / R4-1..4, fix batches A-G + closeout ++ two re-test rounds, final build `1.0.2-cc.o`) and the user declared +**"Gate pass!"** on 2026-08-16. The campaign's headline milestone — the +FIRST live character created by acdream against ACE — was reached +mid-round on build `1.0.2-cc.g`. CC7 (the final slice) closed out the campaign's implementation: the Create button un-ghosts and opens chargen for real, the full 0xF656/0xF643 flow is proven end-to-end against a real WorldSession, the launcher status-payload cycle is proven @@ -286,4 +291,4 @@ the user gate. | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | REVIEW-CLOSED 2026-08-16 | `9cf6c522`, `ddcbf1fb`, F1-F9 review-fix round `2176ba76` | CLOSED (dual-lens: both lenses PASS-with-items → F1-F9 fix round this commit; lead diff-check close per the doc/test-only residual pattern) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. **Corrected at the CC7 review-fix round, F7 (2026-08-16): the original fixture-ordering claim above was WRONG.** The shared fixture originally constructed chargen FIRST so its `Open` method existed to wire into management's `RequestCreate` binding — the OPPOSITE of production's real tick order (`RetailUiRuntime.Tick`: `_characterManagementMount?.Tick(); CharacterManagementController?.Tick(); _characterCreationMount?.Tick(); CharacterCreationController?.Tick();` — management always ticks first). The fixture now constructs management first, handing it a lazily-resolved closure over chargen's not-yet-existing `Controller.Open` — the SAME trick production's own `RetailUiRuntime.ConfigureCharacterManagement` uses (`bindings with { RequestCreate = () => CharacterCreationController?.Open() }`) — matching production's real construction AND tick order instead of contradicting it. The test also now asserts `Chargen.Controller.Root.ClickThrough == false` and a strictly higher `ZOrder` than management's root once both controllers have ticked with chargen open, pinning the `BringToFront` occlusion effect the reviewer had previously verified only by manual inspection. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum. **Corrected at the CC7 review-fix round, F6 (2026-08-16): the checksum half of that claim overstated what the assertion proves.** `Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum)` (`LiveSessionControllerCharacterCreationTests.cs:537`) is a round-trip/purity check — it computes the SAME production `CharacterCreate.ComputeChecksum` on both the encode and the decode side, not an independent golden value. It still closes the one gap (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 non-checksum fields were previously unverified); the checksum's actual golden value lives separately at `CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet` (the 19-term sum, golden `205u`), now cross-referenced from this test's own doc comment. `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). **Review fix round (this commit, F1-F9), CC7 REVIEW-CLOSED:** F1 files AP-229 for the screen-layering divergence the reviewer flagged (retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch; acdream keeps both `CharacterManagementUiController`/`CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes), records what the reviewer confirmed already works (selection/world-name persistence, click-through isolation, one coherent `Modal` stack), and the narrow residual risk it left open (the shared `RetailDialogFactory` can hand `UiRoot.Modal` to a dialog opened by the still-ticking, occluded management screen's `ReconcileDialogs` on an inbound `CharacterError` — a race retail cannot have since the occluded screen simply does not exist there). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe (ACE default 11, confirmed against `references/ACE/Source/ACE.Server/Command/Handlers/AdminCommands.cs:4393`) and the pending-delete-counts-too note. F3 adds AP-221's exact console-diagnostic lines to §CC2's known-gaps paragraph so a session-permanent dead preview reads as a known gap, not a fresh bug. F4 adds an empty-name/AP-227 step to §CC3 so the tester expects acdream's `NoNameWarning` dialog instead of retail's silent keep-old-name behavior. F5 adds an App-layer source-text pin (`GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter`) for the `CharacterCreated`/`CreationFailed` delegate wiring inside `LiveSessionRuntimeFactory.cs:229-236` the reviewer proved was deletable without breaking any test — no practical seam exists to construct the factory end-to-end without a `GameWindow` (confirmed: its one production construction site is deep inside `SessionPlayerComposition.cs`, and no test in the repo constructs it directly), so the pin follows this same test file's own established source-text pattern (`ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot`, `DisplacedLifecycleBodiesAreAbsent`) rather than a contrived full construction; the exact payload SHAPE these delegates produce was already pinned separately at `SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape`, so the new test plus that existing one together cover "bound" and "correct payload." F6/F7 correct this row's own wording above (checksum-assertion circularity; fixture construction order) and strengthen `CharacterScreensFixedCanvasArbiterTests` per F7's fix. F8 records a known flake found under full-solution parallel load on both reviewer runs (passes standalone, unrelated to CC7 — an allocation assertion sensitive to concurrent load): `AcDream.Runtime.Tests.Physics.RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, joining the existing Core.Net NakEmission / Content DecodedTextureCache / App SocialPanelLiveMountProbeTests known-flake set. F9 adds a one-line note to §CC2's Heritage-page Random step that a uniform pick over 13 heritages can repeat the current one. **Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate** (`docs/research/2026-08-16-campaign-cc-test-script.md`) — no automated live character creation has touched ACE yet; that gate remains the sole outstanding acceptance step. | | CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, `d2a71152`, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round `d2a71152` → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of `d2a71152` (R3) after re-deriving from the decomp: `gmCGAppearancePage::ListenToElementMessage`'s own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — `DoGradDisk` is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever `SetColor`/`SetSelection` run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; **the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of `d2a71152`, N2)** — none of the nine spins author Highlight-state media, so `RefreshColorAndShadeControls`'s `TrySetRetailState(Highlight)` call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. | -| Gate round 1 | CLOSED 2026-08-16 (batches A-G plus a dedicated closeout round; supersedes this ledger's "sole remaining acceptance step" framing above — that framing predates the user's connected gate, which found the six-page findings batch GF-1..GF-16, then re-tested and found R2-1..R2-8, both fully fixed across this round) | Batches: `1d9de5e0` (A — GF-15 input/GF-5 skills rows/GF-13 GM toggles), `7d09821f` (B — authored selection states/label state/zoom-swatch feedback), `0591b9a0`+`5190e169`+`2349f8b4` (C — rich text/labels/backdrops, client-wide un-consume carve-out, Summary how-to+scrollbar), `63bf64c9` (D — gmCG3DView environment backdrop), `e24ec208` (E — text origin/caption escapes/value rects/scrollbars/name prefill), `8c30aa18` (F — Skills page buckets/selection/info box/cost text/arrow states, partial — the four-bucket model itself deferred to the closeout below), `834c2547` (G — real color wheel DoColorSpots/DoGradDisk color computation, left INERT pending the closeout's wiring). Closeout round (this session, dedicated Sonnet implementer): `e1d7d095` (Group 1 — wires Batch G's two STOPPED items: `UiButton`/`UiDatElement` gain a `Tint` property, the flat-fill overlay is replaced by a genuine multiplicative sprite tint, and the DAT-backed color-source seams are threaded through the composition root), `0fed5fdd` (Group 2 — the Skills page four-bucket sorted model Batch F deferred: `ChargenSkillDetail`/`ChargenSkillFormula` thread `SkillBase.MinLevel`/`Description`/`Formula`, `CharacterCreationSkillsPage` groups/sorts/re-buckets, the info box gets its description+formula completion), `bd359d51` (Group 3 — the round review's remaining findings F4-F11/F14/F16: three UiButton corpus sweeps, a narrow `AuthoredInvisible` honor for the chat new-text indicator, `BoundedProcessOutputCapture`'s single-write `AppendLine`, a stale-comment correction, documented (not code-changed) numeric-asymmetry and harmless-set-membership findings, per-page `DatRichText.Compose` caching, and the Summary preview's own render-id pair closing a real cross-page `TextureCache` collision). Docs-only bookkeeping (register/ISSUES/findings-doc corrections for F3/F12/F15) lands in the commit immediately following this ledger update. | Register bookkeeping across the round: AP-216/AP-217 RETIRED (Group 1), AP-213 RETIRED (Group 2), AP-229/AP-230 amended with closeout addenda (F3/F5-F6), the AP section header's inverted "one high" note corrected to "one low" (F12), the AD section header recounted 77->79 (F12); new AP-231 documents the Skills page formula-connector-text approximation. Gates: full-solution Release build green throughout; App suite (live-DAT env) 5358/3, Runtime 1735/0 (unchanged), Core 4797/1, Content 154/0, Launcher.Core 338/0, and the complete solution (12 test projects) 0 failures / 4 skips at the closeout's own final run. | **STILL OWED:** the user's own connected-gate re-run against this closeout's build (nothing in this round replaces the user's own visual/behavioral confirmation of GF-1..GF-16/R2-1..R2-8's fixes); F3's literal ask (a test driving `RetailUiRuntime.Tick(double)` itself rather than its two components separately) was assessed and NOT implemented — `RetailUiRuntimeBindings` requires ~24 nested sub-binding records with no existing lightweight construction path, disproportionate to the value of strengthening an already-correct, already-tested tick-order guarantee (`Finish_EmptyName_RealEventPath_...` already pins the same order via direct calls); AP-231's formula-connector approximation remains unverified against a live retail capture. | +| Gate round 1 | CLOSED 2026-08-16 (batches A-G plus a dedicated closeout round; supersedes this ledger's "sole remaining acceptance step" framing above — that framing predates the user's connected gate, which found the six-page findings batch GF-1..GF-16, then re-tested and found R2-1..R2-8, both fully fixed across this round) | Batches: `1d9de5e0` (A — GF-15 input/GF-5 skills rows/GF-13 GM toggles), `7d09821f` (B — authored selection states/label state/zoom-swatch feedback), `0591b9a0`+`5190e169`+`2349f8b4` (C — rich text/labels/backdrops, client-wide un-consume carve-out, Summary how-to+scrollbar), `63bf64c9` (D — gmCG3DView environment backdrop), `e24ec208` (E — text origin/caption escapes/value rects/scrollbars/name prefill), `8c30aa18` (F — Skills page buckets/selection/info box/cost text/arrow states, partial — the four-bucket model itself deferred to the closeout below), `834c2547` (G — real color wheel DoColorSpots/DoGradDisk color computation, left INERT pending the closeout's wiring). Closeout round (this session, dedicated Sonnet implementer): `e1d7d095` (Group 1 — wires Batch G's two STOPPED items: `UiButton`/`UiDatElement` gain a `Tint` property, the flat-fill overlay is replaced by a genuine multiplicative sprite tint, and the DAT-backed color-source seams are threaded through the composition root), `0fed5fdd` (Group 2 — the Skills page four-bucket sorted model Batch F deferred: `ChargenSkillDetail`/`ChargenSkillFormula` thread `SkillBase.MinLevel`/`Description`/`Formula`, `CharacterCreationSkillsPage` groups/sorts/re-buckets, the info box gets its description+formula completion), `bd359d51` (Group 3 — the round review's remaining findings F4-F11/F14/F16: three UiButton corpus sweeps, a narrow `AuthoredInvisible` honor for the chat new-text indicator, `BoundedProcessOutputCapture`'s single-write `AppendLine`, a stale-comment correction, documented (not code-changed) numeric-asymmetry and harmless-set-membership findings, per-page `DatRichText.Compose` caching, and the Summary preview's own render-id pair closing a real cross-page `TextureCache` collision). Docs-only bookkeeping (register/ISSUES/findings-doc corrections for F3/F12/F15) lands in the commit immediately following this ledger update. | Register bookkeeping across the round: AP-216/AP-217 RETIRED (Group 1), AP-213 RETIRED (Group 2), AP-229/AP-230 amended with closeout addenda (F3/F5-F6), the AP section header's inverted "one high" note corrected to "one low" (F12), the AD section header recounted 77->79 (F12); new AP-231 documents the Skills page formula-connector-text approximation. Gates: full-solution Release build green throughout; App suite (live-DAT env) 5358/3, Runtime 1735/0 (unchanged), Core 4797/1, Content 154/0, Launcher.Core 338/0, and the complete solution (12 test projects) 0 failures / 4 skips at the closeout's own final run. | **USER-PASSED 2026-08-16 on build `1.0.2-cc.o`** after two further re-test fix rounds this ledger row predates: re-test 2 (`7d6a7898`..`91f84dec`, R3-1..R3-9 — one-line captions per retail's OneLine gate, info-pane VJustify scoped fix + #410/AD-104, the single-sprite scrollbar-thumb fallback, retail's ReplaceColor spot bake replacing the tint approximation, the third exhaustive `[ Name ]` negative) and re-test 3 (`e6acb800`, R4-1..R4-4 — the base-inherited value-child reflow, the DrawTiled→single thumb marker, the pane-taller-than-frame clamp + AD-105, the PreserveEndOnLayout first-overflow pin). Residual notes carried: F3's literal ask (a test driving `RetailUiRuntime.Tick(double)` itself rather than its two components separately) was assessed and NOT implemented — `RetailUiRuntimeBindings` requires ~24 nested sub-binding records with no existing lightweight construction path, disproportionate to the value of strengthening an already-correct, already-tested tick-order guarantee (`Finish_EmptyName_RealEventPath_...` already pins the same order via direct calls); AP-231's formula-connector approximation remains unverified against a live retail capture. | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index be181039..0dfe688e 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1152,3 +1152,15 @@ unrelated to this batch's files). No client launches. (the shared `BuildAppearancePage` fixture gained the help box + its nested scrollbar child, StateMedia-bearing so `UiText`'s own dat- children carve-out actually builds it). + +## GATE PASSED — 2026-08-16, build `1.0.2-cc.o` + +The user's final pass on `1.0.2-cc.o` (carrying the R4 fixes) returned +**"Ok works. Gate pass!"** — Campaign CC's connected gate is CLOSED +USER-ACCEPTED. Every finding family in this doc (GF-1..16, R2-1..8, +R3-1..9, R4-1..4) is fixed and user-verified, except the two explicitly +dispositioned non-bugs: the `[ Name ]` prefill (three independent +exhaustive negatives — retail's field is empty; closed as a recollection +slip unless retail evidence surfaces) and the items deferred to their own +tracked issues (#408 general authored-Invisible, #409 tooltips, #410 +VJustify default, AP-231's formula connector). From 922c3f3d1a0aab78a994325b0c3dd9a5172f76d5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 19:13:55 +0200 Subject: [PATCH 138/138] =?UTF-8?q?docs:=20CLAUDE.md=20Current=20state=20?= =?UTF-8?q?=E2=80=94=20Campaign=20CC=20CLOSED=20user-accepted=202026-08-16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b42b3fff..256acf73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -256,9 +256,19 @@ strict status/redaction evidence, and the exact Windows/Ubuntu operator script are landed; the integrated preflight passes 32/32 commands and 14,012 tests / 5 skips. Only the connected/visual/real-DAT user gate remains before shipment. -**Campaign CC — retail character creation (CODE-COMPLETE 2026-08-16, all -seven slices REVIEW-CLOSED; the user's connected gate is the sole -outstanding acceptance step).** The full retail creation flow: Create +**Campaign CC — retail character creation (CLOSED USER-ACCEPTED +2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as +one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches +A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and +PASSED. **Milestone: the first live character ever created by acdream +against ACE landed mid-round.** The gate round's own harvest hardened +shared surfaces well beyond chargen: authored text margins (P0x23-26), +the authored Unselected/Selected state pair + per-state label color, +un-consumed Type-12 media children (frames/scrollbars client-wide), +single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the +dialog-always-on-top re-raise (the invisible-modal input blackhole), a +truthful client crash self-report + bounded stderr capture (#405-#407 +fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create button (retail's exact `UpdateButtons` roster