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); + } +}