From 55bfd9ca820fdf4695e4fca8b2547fad8f48ca6f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 17:21:34 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice=20CC?= =?UTF-8?q?6a=20=E2=80=94=20index=E2=86=92ObjDesc=20factory=20+=20preview?= =?UTF-8?q?=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 1774d8b29847ab5ee5f91fc890d82cdd7fdbabef Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 17:51:14 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC6a=20revi?= =?UTF-8?q?ew=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 8dfee1118f5be0a760a60e30a180942f194d5b58 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:05:56 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice=20CC?= =?UTF-8?q?6b-PRE=20=E2=80=94=20idle=20loop,=20rotation,=20zoom=20(mount-i?= =?UTF-8?q?ndependent=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 1ba22a01a8fbb125564775fbf8a6b0cf5e2fb560 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:32:48 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20CC6b-PRE=20?= =?UTF-8?q?review=20fix=20round=20=E2=80=94=20F1-F7=20+=20F11=20concession?= =?UTF-8?q?=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 5/5] 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