From 8dfee1118f5be0a760a60e30a180942f194d5b58 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 19:05:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice=20CC6b-P?= =?UTF-8?q?RE=20=E2=80=94=20idle=20loop,=20rotation,=20zoom=20(mount-indep?= =?UTF-8?q?endent=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle animation loop: decomp re-read of gmCGAppearancePage::Update's trailing StartAnimation/StopAnimation gate (~0x0047EF01-0x0047EF12) plus the ctor evidence that m_bZoomedIn is a decompiler-elided bool (never explicitly set away from its zero default, unlike its two sibling bools) establishes that retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose CC6a shipped as a deliberate simplification (TS-83) — the rest pose only appears once Zoom In fires. New Core primitive RetailAnimationCyclePlayback ports CPhysicsObj::set_sequence_animation's advance-with-wrap + lerp/slerp effect (the same algorithm LiveEntityAnimationPresenter's legacy NPC-idle branch already carries inline; not consolidated this round — out of blast radius for a preview-only feature, noted in the new type's own doc). New ChargenPreviewAnimator drives the per-tick swap; ChargenPreviewEntityBuilder gained TryBuildAnimated alongside the byte-behavior-unchanged TryBuild. Olthoi/OlthoiAcid use the SAME enum key for idle and rest DIDs (decomp-confirmed quirk). TS-83 retired in the register (§4 count 50->49). Rotation controller: ChargenPreviewRotationController ports Rotate/DoRotation (0x0047CB50/0x0047CA80) verbatim — toggle-to-stop, deltaDegrees = ((now-last)/RotationSecondsPerRevolution)*360, single-pass +-360 clamp (not a full modulo, matching retail's own tail), the -1.0 invalidation sentinel. Applies to the entity's heading via the existing MoveToMath.SetHeading port, not the camera, confirming CC6a's own note. Zoom tween: ChargenPreviewZoomController ports ZoomIn/ZoomOut/ DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) — a LINEAR 0.6s tween (no easing curve in the decomp) between the already-recorded camera eye profiles, calling into the animator's zoom swap IMMEDIATELY at button-press time, matching retail's call order exactly. m_alternateSetupID (research correction): re-reading the decomp function-by-function found all five m_alternateSetupID write sites — including the two the CC6a review cited — belong to gmBarberUI (the post-creation barber shop), not gmCGAppearancePage, which has no m_pOption1Checkbox-equivalent field and never writes the field. For character creation the field is always INVALID_DID in retail. TryCompose still gained a real, decomp-cited alternateSetupIdOverride parameter (default no-op) implementing gmCG3DView::Update's generic override precedence, for a future non-chargen consumer. RetailHeldPose extraction: shared ResolvePoseDid/ComposePartTransform between RetailPaperdollPoseApplicator and ChargenPreviewEntityBuilder — a clean mechanical extraction, behavior-identical on the paperdoll side. Bookkeeping: CC6a ledger row now cites its real commit SHAs (55bfd9ca, 1774d8b2); new CC6b-PRE ledger row records scope done + the page-mount half still owed. Tests: RetailAnimationCyclePlaybackTests (10, Core), ChargenAppearanceFactoryTests (+4), ChargenPreviewRotationControllerTests (9), ChargenPreviewZoomControllerTests (7), ChargenPreviewAnimatorTests (7, hand-built fixtures), ChargenPreviewEntityBuilderTests (+5, installed-DAT). Core.Tests 4786/1 skip, Content.Tests 147/0, App.Tests 5149/6 skips — zero failures, full solution Release build green. One pre-existing, unrelated flake noted: Core.Net.Tests' NakEmissionTests loss soak failed once in the full-suite run, passed 1/1 isolated. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../2026-08-15-character-creation-campaign.md | 4 +- .../Rendering/ChargenPreviewAnimator.cs | 135 ++++++++++ .../Rendering/ChargenPreviewCamera.cs | 13 +- .../Rendering/ChargenPreviewEntityBuilder.cs | 233 ++++++++++++++---- .../Rendering/ChargenPreviewRenderer.cs | 36 ++- .../ChargenPreviewRotationController.cs | 114 +++++++++ .../Rendering/ChargenPreviewZoomController.cs | 135 ++++++++++ .../Rendering/PaperdollFramePresenter.cs | 30 +-- src/AcDream.App/Rendering/RetailHeldPose.cs | 61 +++++ .../CharGen/ChargenAppearanceFactory.cs | 57 ++++- .../Physics/RetailAnimationCyclePlayback.cs | 123 +++++++++ .../Rendering/ChargenPreviewAnimatorTests.cs | 154 ++++++++++++ .../ChargenPreviewEntityBuilderTests.cs | 151 ++++++++++++ .../ChargenPreviewRotationControllerTests.cs | 117 +++++++++ .../ChargenPreviewZoomControllerTests.cs | 166 +++++++++++++ .../CharGen/ChargenAppearanceFactoryTests.cs | 80 ++++++ .../RetailAnimationCyclePlaybackTests.cs | 153 ++++++++++++ 18 files changed, 1657 insertions(+), 108 deletions(-) create mode 100644 src/AcDream.App/Rendering/ChargenPreviewAnimator.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewRotationController.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewZoomController.cs create mode 100644 src/AcDream.App/Rendering/RetailHeldPose.cs create mode 100644 src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1f0370f4..30946327 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -389,11 +389,10 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 50 active rows (TS-83 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview holds a static rest-pose final frame instead of retail's live 30fps idle loop, explicitly staged for CC6b to retire; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — which the ctor never explicitly sets away from its zero-initialized default — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-82 filed 2026-08-15 at Campaign CC slice CC6a, corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-83 | Chargen 3D preview (Campaign CC slice CC6a foundation): the preview holds a STATIC final-frame rest pose (`ChargenPreviewEntityBuilder.ApplyHeldPose`, retail's `m_didAnimationRest` DID resolution) instead of retail's live 30fps idle loop (`gmCG3DView`'s `m_didAnimation`/`m_didAnimArray` family, driven via `set_sequence_animation`). Deliberately staged, not discovered late: the campaign plan's own CC6 slice row names this exact split ("CC6a static-pose preview... register row for the missing idle loop, CC6b idle animation... retire the row"). | `src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs` (`ApplyHeldPose`); `src/AcDream.App/Rendering/ChargenPreviewRenderer.cs` | Explicitly staged per `docs/plans/2026-08-15-character-creation-campaign.md`'s CC6 slice split; the identical held-pose technique is the paperdoll's own PERMANENT (not staged) design (`RetailPaperdollPoseApplicator.Apply`), so the mechanism itself is proven, only the "hold forever vs. play then hold" choice is temporary here. | The chargen preview shows a motionless character instead of retail's idle sway/breathing loop — cosmetic only; does not affect the composed appearance data (setup id, palette, part/texture overrides) CC6b's page will bind to. | `gmCG3DView` ctor + `::Update @ 0x004EE9D0` (`m_didAnimation`/`m_didAnimArray`/`m_didAnimationRest` DID assignments, pseudo-C ~0x004EE7C6-0x004EE995); `CreatureMode::set_sequence_animation` (idle-loop playback entry point, not yet located precisely — CC6b to find) | | TS-82 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index bea19fab..e3ddc703 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -253,8 +253,8 @@ the user gate. | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | — | | | | | CC5 | — | | | | -| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | single commit, HEAD of `campaign-cc6a` (plus a same-session review fix-round commit, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-82 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-82's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-82 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | -| CC6b | NOT STARTED | | | **MUST-COVER, carried from the CC6a review fix round (F11/F12):** (1) retail's SECOND Setup-override source — `gmCG3DView`'s `m_alternateSetupID`, set from the Appearance page's option checkbox (Penumbraen crown variant `@0x004DFB3F`, Undead no-flame variant `@0x004E0C54`), takes precedence over the hair style's `AlternateSetup` at `gmCG3DView::Update`'s own resolution (`@0x004EEA51`) — CC6a's factory only ports the hair-style source; this second source is completely unmodelled and needs its own citation-backed port + register-row bookkeeping if CC6b doesn't fully close it. (2) Before adding a FOURTH consumer of the "resolve a rest-pose DID via master-map slot 7, load its Animation, hold the final frame" algorithm (paperdoll's `RetailPaperdollPoseApplicator`, CC6a's `ChargenPreviewEntityBuilder.ApplyHeldPose`/`ResolvePoseDid` are the second and third), extract a shared `RetailHeldPose` helper rather than copying it a third time. | +| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | single commit, HEAD of `campaign-cc6a` | Review outstanding (dual-lens Opus pass not yet run this round) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the ctor evidence that `m_bZoomedIn` is one of three consecutive bool bytes the decompiler shows only two of (`m_bShouldZoomAnimate`/`m_bRotating` explicitly zeroed, `m_bZoomedIn` never explicitly touched — the same decompiler-elision class `claude-memory/feedback_bn_decomp_field_names.md` warns about) settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept byte-behavior-identical — a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI::ListenToElementMessage`/`::InitializePage` (confirmed via the enclosing-function scan: `gmBarberUI::SetSelection`/`::Rotate` calls and a `CM_Character::Event_FinishBarber` wire call sit in the SAME function bodies), the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`, which has NO `m_pOption1Checkbox`-equivalent field anywhere in its own field list (`acclient.h:56373-56428`, checked exhaustively) and never writes `m_alternateSetupID` in any of its own methods. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, this campaign's own scope. `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism for a future non-chargen consumer of this same factory, not a fabricated feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (9), `ChargenPreviewZoomControllerTests` (7), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (+14 from the CC6a baseline of 4772/1), Content.Tests 147/0 skips (unchanged — no Content-layer work this round), App.Tests 5149/6 skips (+28 from 5121/6) — zero failures, full solution Release build green. One PRE-EXISTING flake noted, not caused by this round: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` failed once in the full-suite run, passed 1/1 in isolation — a randomized-loss-injection timing flake in the unrelated Core.Net transport suite (zero files under `src/AcDream.Core.Net/` touched this round). **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to the three new controllers' `Tick`/`Toggle`/`ZoomIn`/`ZoomOut` methods, spin controls, color wheels. | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs new file mode 100644 index 00000000..51f144d3 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Owns the chargen preview's per-frame idle-loop ↔ rest-pose playback, +/// mirroring gmCG3DView::StartAnimation/StopAnimation's swap +/// (0x004EE600/0x004EE640) and +/// gmCGAppearancePage::ZoomIn/ZoomOut's immediate call into it +/// (0x0047D024/0x0047D160 — the swap happens the instant the +/// button is pressed, NOT once the camera's own 0.6s tween finishes). +/// +/// +/// Retail default is idle-PLAYING, not frozen — see +/// 's class doc for the decomp +/// citations. This class's own default ( starts +/// false) reproduces that: its constructor immediately plays the +/// idle animation's frame 0 when one resolved, matching +/// gmCGAppearancePage::Update's own trailing +/// if (m_bZoomedIn == 0) StartAnimation() gate +/// (~0x0047EF01-0x0047EF12), which re-fires on every heritage/gender/ +/// appearance change too — restarts the idle loop +/// at frame 0 on every transition INTO the playing state for the same +/// reason: set_sequence_animation's arg3=1 clears the sequence +/// before appending, so every StartAnimation call restarts the clip. +/// +/// +/// +/// The page-mount half (CC6b, after CC4 merges) wires the Zoom In/Out +/// buttons to and the render loop to +/// ; nothing in this repository calls either yet. +/// +/// +internal sealed class ChargenPreviewAnimator +{ + /// + /// gmCG3DView::StartAnimation's literal framerate argument + /// (set_sequence_animation(this->m_pPlayerObject, + /// this->m_didAnimation.id, 1, 0, 30f), pseudo-C ~0x004ee61b). + /// + public const float IdleFramerate = 30f; + + private readonly ChargenPreviewAnimatedBuild _build; + private float _currFrame; + private bool _zoomedIn; + + public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build) + { + _build = build ?? throw new ArgumentNullException(nameof(build)); + _currFrame = build.IdleLowFrame; + if (build.IdleAnimation is not null) + ApplyIdleFrame(); // retail's true default: idle playing, frame 0. + // Else: Entity.MeshRefs already holds RestMeshRefs (set by + // TryBuildAnimated) as the best available fallback. + } + + /// The live preview entity — mutated in place by + /// and ; the renderer never needs to re-call + /// SetPreview after the first assignment (WorldEntity.MeshRefs + /// is read fresh every draw — see its own doc comment). + public WorldEntity Entity => _build.Entity; + + public bool IsZoomedIn => _zoomedIn; + + /// + /// gmCGAppearancePage::ZoomIn/ZoomOut's + /// StopAnimation/StartAnimation call, applied immediately + /// (retail does not wait for the camera tween to finish before swapping + /// animation state — see this class's own doc comment). No-op if + /// already in the requested state, matching retail's own early-return + /// guards (ZoomIn's if (m_bZoomedIn != 0) return, + /// ZoomOut's mirror). + /// + public void SetZoomedIn(bool zoomedIn) + { + if (_zoomedIn == zoomedIn) + return; + _zoomedIn = zoomedIn; + if (zoomedIn) + { + _build.Entity.MeshRefs = _build.RestMeshRefs; + } + else + { + _currFrame = _build.IdleLowFrame; + if (_build.IdleAnimation is not null) + ApplyIdleFrame(); + } + } + + /// + /// Advances the idle loop by . No-op + /// while zoomed in (the rest pose is frozen — retail's framerate-0 + /// set_sequence_animation call never advances) or when no idle + /// Animation resolved (heritage/DID gap; the entity keeps whatever pose + /// the constructor seeded). + /// + public void Tick(float elapsedSeconds) + { + if (_zoomedIn || _build.IdleAnimation is null || elapsedSeconds <= 0f) + return; + + _currFrame = RetailAnimationCyclePlayback.Advance( + _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, IdleFramerate, elapsedSeconds); + ApplyIdleFrame(); + } + + private void ApplyIdleFrame() + { + DatReaderWriter.DBObjs.Animation animation = _build.IdleAnimation!; + IReadOnlyList parts = _build.DrawableParts; + var meshRefs = new List(parts.Count); + foreach (ChargenPreviewDrawablePart part in parts) + { + bool resolved = RetailAnimationCyclePlayback.TryInterpolatePart( + animation, _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, + part.SetupPartIndex, out Vector3 origin, out Quaternion orientation); + // Same defensive default as ApplyHeldPoseTransforms: a part + // index the bracketing frame doesn't cover (a Setup/Animation + // part-count mismatch, never expected in practice) keeps + // identity rather than a degenerate zero quaternion. + if (!resolved) + { + origin = Vector3.Zero; + orientation = Quaternion.Identity; + } + Matrix4x4 transform = RetailHeldPose.ComposePartTransform(part.DefaultScale, origin, orientation); + meshRefs.Add(new MeshRef(part.GfxObjId, transform) { SurfaceOverrides = part.SurfaceOverrides }); + } + _build.Entity.MeshRefs = meshRefs; + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs index 138dab5f..98db610d 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -25,10 +25,15 @@ namespace AcDream.App.Rendering; /// button (gmCGAppearancePage::DoRotation @ 0x0047CA80) advances a /// HEADING applied to the preview CHARACTER (CPhysicsObj::set_heading /// inside gmCG3DView::Update, pseudo-C ~242088) — the camera's own -/// position/direction never change during a rotation. CC6b's heading -/// parameter therefore belongs on the entity builder -/// (), not here; this class stays a -/// fixed-per-heritage eye, exactly like retail's own camera. +/// position/direction never change during a rotation. The heading itself +/// lives on (CC6b: the +/// DoRotation/Rotate port) and is applied to the entity via +/// ChargenPreviewEntityBuilder.TryBuild/TryBuildAnimated's +/// heading parameter, not here; this class stays a fixed-per-heritage +/// eye, exactly like retail's own camera. +/// (CC6b: the ZoomIn/ZoomOut/DoZoomAnimation port) DOES +/// mutate this class's — zoom is a camera concern, unlike +/// rotation. /// /// public sealed class ChargenPreviewCamera : ICamera diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs index 0717430a..bc040a1d 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -10,7 +10,50 @@ using DatReaderWriter.DBObjs; namespace AcDream.App.Rendering; /// -/// Builds the static-pose chargen preview from a +/// One resolved drawable part of the chargen preview body — a Setup part +/// index (needed to sample Animation.PartFrames[frame].Frames[index] +/// and Setup.DefaultScale[index]) paired with its resolved GfxObj id, +/// default scale (captured once at build time — scale never changes across +/// an idle cycle), and surface overrides. +/// walks this list every tick without touching the dat source again. +/// +internal readonly record struct ChargenPreviewDrawablePart( + int SetupPartIndex, + uint GfxObjId, + Vector3 DefaultScale, + IReadOnlyDictionary? SurfaceOverrides); + +/// +/// The richer sibling of 's +/// result: the built (seeded with retail's true +/// default pose — see ) plus everything +/// needed to drive it frame-by-frame without re-touching the dat source — +/// the resolved drawable parts, the precomputed frozen rest pose, and the +/// resolved idle Animation + its frame range. +/// +internal sealed class ChargenPreviewAnimatedBuild +{ + public required WorldEntity Entity { get; init; } + public required IReadOnlyList DrawableParts { get; init; } + + /// + /// The held final-frame rest pose, precomputed once (retail: + /// gmCG3DView::StopAnimation's framerate-0 + /// set_sequence_animation call never advances, so there is + /// nothing to recompute per tick while zoomed in). Falls back to each + /// part's raw Setup-default transform (no-op) when the rest DID doesn't + /// resolve, matching the pre-CC6b ApplyHeldPose no-op behavior. + /// + public required IReadOnlyList RestMeshRefs { get; init; } + + /// Retail's live idle DID (m_didAnimation), or null if unresolved. + public Animation? IdleAnimation { get; init; } + public int IdleLowFrame { get; init; } + public int IdleHighFrame { get; init; } +} + +/// +/// Builds the chargen preview from a /// — the App-layer counterpart to /// , except this one resolves its OWN /// MeshRefs from a Setup + the composed ObjDesc rather than receiving @@ -20,7 +63,30 @@ namespace AcDream.App.Rendering; /// closest existing precedent for the actual mesh-flatten/apply-changes/ /// resolve-surface-overrides steps is /// DatLiveEntityProjectionMaterializer.TryMaterialize, trimmed to -/// what a private, non-animated, non-collision preview scene needs. +/// what a private, non-collision preview scene needs. +/// +/// +/// CC6b: retail's chargen preview does NOT default to a frozen pose — +/// gmCGAppearancePage::Update's own trailing gate +/// (~0x0047EF01-0x0047EF12) calls gmCG3DView::StartAnimation (idle +/// loop playing) whenever m_bZoomedIn == 0, and that field is never +/// explicitly initialized away from its zero-initialized default in the +/// ctor (gmCGAppearancePage::gmCGAppearancePage, pseudo-C +/// ~0x0047CD58-0x0047CD64 — m_bShouldZoomAnimate/m_bRotating/ +/// m_bZoomedIn are three consecutive bool bytes the decompiler shows +/// only the first two of, a known decompiler-elision class per +/// claude-memory/feedback_bn_decomp_field_names.md). So retail's +/// chargen preview plays its idle loop (m_didAnimation, 30fps) from +/// the very first frame; the REST pose (m_didAnimationRest, held +/// final frame, this class's pre-CC6b-only behavior) only appears once the +/// user presses Zoom In (gmCGAppearancePage::ZoomIn calls +/// gmCG3DView::StopAnimation immediately, before its camera tween +/// even starts). keeps its ORIGINAL (rest-only) +/// behavior unchanged for its existing callers; +/// plus are the new, retail-accurate +/// entry point a live preview (idle-playing by default, freezing on zoom-in) +/// should use. +/// /// internal static class ChargenPreviewEntityBuilder { @@ -37,8 +103,8 @@ internal static class ChargenPreviewEntityBuilder public const uint PreviewRenderId = 0xDA11_D032u; /// - /// Retail's held-pose animation DID enum key, resolved through master - /// map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid + /// Retail's held-pose (REST) animation DID enum key, resolved through + /// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid /// — 0x10000005 for every standard heritage (the SAME enum id the /// paperdoll's own held pose reads), matching /// gmCG3DView's ctor / ::Update per-heritage @@ -55,10 +121,35 @@ internal static class ChargenPreviewEntityBuilder }; /// - /// Builds the preview entity, or null when the resolved body Setup - /// isn't in the dat source (a corrupted/incomplete install — the same - /// failure shape treats - /// as "drop this spawn"). + /// Retail's LIVE idle-loop animation DID enum key (m_didAnimation, + /// the one gmCG3DView::StartAnimation plays at 30fps) — 0x10000006 + /// for every standard heritage, matching gmCG3DView's ctor / + /// ::Update per-heritage assignment (pseudo-C ~0x004ee6cc, + /// ~0x004eec2d). Olthoi and OlthoiAcid use the SAME did for BOTH idle + /// and rest (0x10000011 / 0x10000013 respectively, pseudo-C + /// ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8) — a genuine retail + /// quirk, not a porting shortcut: those two heritages show no visible + /// difference between "idle playing" and "zoomed in and frozen" in the + /// chargen preview. + /// + private static uint ResolveIdleAnimEnum(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => 0x10000011u, + (uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u, + _ => 0x10000006u, + }; + + /// + /// Builds the STATIC (held rest-pose) preview entity, or null when the + /// resolved body Setup isn't in the dat source (a corrupted/incomplete + /// install — the same failure shape + /// treats as "drop this + /// spawn"). Unchanged since CC6a — a thin wrapper over + /// that keeps this method's existing + /// callers' behavior byte-identical. New code that wants retail's true + /// default (idle loop playing) should call + /// and wrap the result in a + /// instead. /// /// /// Shared exclusion object for every dat read this method performs. @@ -79,21 +170,48 @@ internal static class ChargenPreviewEntityBuilder uint heritageId, Quaternion heading, object datLock) + { + ChargenPreviewAnimatedBuild? build = TryBuildAnimated( + dats, animations, appearance, heritageId, heading, datLock); + if (build is null) + return null; + + build.Entity.MeshRefs = build.RestMeshRefs; + return build.Entity; + } + + /// + /// Builds the preview entity PLUS everything a + /// needs to drive retail's idle-loop ↔ rest-pose swap without re-touching + /// the dat source. The returned + /// is initially posed with + /// (cheap, always available) — 's + /// constructor immediately reposes it to the true retail default (idle + /// frame 0) when an idle Animation resolved. + /// + public static ChargenPreviewAnimatedBuild? TryBuildAnimated( + IDatReaderWriter dats, + IAnimationLoader animations, + ChargenAppearanceResult appearance, + uint heritageId, + Quaternion heading, + object datLock) { ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(animations); ArgumentNullException.ThrowIfNull(appearance); ArgumentNullException.ThrowIfNull(datLock); - List meshRefs; uint setupId = appearance.SetupId; - PaletteOverride? paletteOverride; - PartOverride[] partOverrides; + List drawableParts; + List restMeshRefs; + Animation? idleAnimation; + int idleLowFrame = 0, idleHighFrame = -1; - // Every dat read this method performs — the Setup fetch, the held- - // pose animation resolution, the per-part GfxObj drawable checks, - // and the texture-change surface resolution — happens inside this - // one lock, mirroring RetailPaperdollPoseApplicator.Apply's "resolve + // Every dat read this method performs — the Setup fetch, both pose + // DID resolutions, the per-part GfxObj drawable checks, and the + // texture-change surface resolution — happens inside this one lock, + // mirroring RetailPaperdollPoseApplicator.Apply's "resolve // everything under lock, then do pure processing" shape. lock (datLock) { @@ -109,12 +227,16 @@ internal static class ChargenPreviewEntityBuilder flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); } - ApplyHeldPose(dats, animations, setup, heritageId, flattened); + // Rest pose: overwrite flattened's transforms with the held + // final frame (no-op — keeps Setup-default transforms — if the + // rest DID or its Animation don't resolve). + ApplyHeldPoseTransforms(dats, animations, setup, ResolveRestPoseEnum(heritageId), flattened); Dictionary>? surfaceOverrides = ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges); - meshRefs = new List(flattened.Count); + drawableParts = new List(flattened.Count); + restMeshRefs = new List(flattened.Count); for (int partIndex = 0; partIndex < flattened.Count; partIndex++) { MeshRef part = flattened[partIndex]; @@ -125,27 +247,52 @@ internal static class ChargenPreviewEntityBuilder if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) overrides = perPart; - meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + restMeshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + + Vector3 defaultScale = partIndex < setup.DefaultScale.Count + ? setup.DefaultScale[partIndex] + : Vector3.One; + drawableParts.Add(new ChargenPreviewDrawablePart(partIndex, part.GfxObjId, defaultScale, overrides)); } - if (meshRefs.Count == 0) + if (drawableParts.Count == 0) return null; - paletteOverride = BuildPaletteOverride(appearance); - partOverrides = BuildPartOverrides(appearance); + // Idle DID: independent lookup, no mutation of flattened. + uint idleDid = RetailHeldPose.ResolvePoseDid(dats, ResolveIdleAnimEnum(heritageId)); + idleAnimation = (idleDid >> 24) == 0x03u ? animations.LoadAnimation(idleDid) : null; + if (idleAnimation is not null && idleAnimation.PartFrames.Count > 0) + { + idleLowFrame = 0; + idleHighFrame = idleAnimation.PartFrames.Count - 1; + } + else + { + idleAnimation = null; + } } - return new WorldEntity + var entity = new WorldEntity { Id = PreviewRenderId, ServerGuid = PreviewServerGuid, SourceGfxObjOrSetupId = setupId, Position = Vector3.Zero, Rotation = heading, - MeshRefs = meshRefs, - PaletteOverride = paletteOverride, - PartOverrides = partOverrides, + MeshRefs = restMeshRefs, + PaletteOverride = BuildPaletteOverride(appearance), + PartOverrides = BuildPartOverrides(appearance), ParentCellId = null, }; + + return new ChargenPreviewAnimatedBuild + { + Entity = entity, + DrawableParts = drawableParts, + RestMeshRefs = restMeshRefs, + IdleAnimation = idleAnimation, + IdleLowFrame = idleLowFrame, + IdleHighFrame = idleHighFrame, + }; } /// No dat access — pure projection of the already-composed @@ -178,8 +325,8 @@ internal static class ChargenPreviewEntityBuilder } /// - /// Overwrites every part's transform from the resolved rest pose's - /// FINAL frame — same "hold the settled last frame at zero frame rate" + /// Overwrites every part's transform from the resolved pose DID's FINAL + /// frame — same "hold the settled last frame at zero frame rate" /// approach as RetailPaperdollPoseApplicator.Apply /// (RedressCreature @ 0x004A3C22), applied to the FULL /// setup-part-indexed array (before drawable filtering) so the index @@ -187,14 +334,14 @@ internal static class ChargenPreviewEntityBuilder /// GfxObj. No-ops (keeps the default placement frame) when the pose /// DID or its animation can't be resolved. /// - private static void ApplyHeldPose( + private static void ApplyHeldPoseTransforms( IDatReaderWriter dats, IAnimationLoader animations, Setup setup, - uint heritageId, + uint poseEnum, List flattened) { - uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId)); + uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum); if ((poseDid >> 24) != 0x03u) return; @@ -214,32 +361,12 @@ internal static class ChargenPreviewEntityBuilder orientation = frame.Frames[index].Orientation; } - Matrix4x4 transform = Matrix4x4.CreateScale(scale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); - flattened[index] = new MeshRef(flattened[index].GfxObjId, transform); + flattened[index] = new MeshRef( + flattened[index].GfxObjId, + RetailHeldPose.ComposePartTransform(scale, origin, orientation)); } } - /// - /// DBCache::GetDIDFromEnumStatic(poseEnum, 7) equivalent — verbatim - /// port of RetailPaperdollPoseApplicator.ResolvePoseDid, - /// parameterized by the target enum key. - /// - private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum) - { - uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; - if (masterDid == 0 - || !dats.Portal.TryGet(masterDid, out var master) - || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) - || !dats.Portal.TryGet(subDid, out var sub)) - { - return 0u; - } - - return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u; - } - /// /// Part-index → (old texture id → new texture id) resolution, verbatim /// port of DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides's diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs index ec6c92c8..fa6a7490 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -14,22 +14,32 @@ namespace AcDream.App.Rendering; /// paperdoll's fixed one. /// /// -/// NOT wired here (CC6b, after CC4 merges per the campaign's parallelism -/// contract): mounting into the authored Appearance/Summary viewport ids -/// (0x100003bb / 0x10000406), spin/color-wheel controls, and -/// the rotate/zoom buttons. This class is a standalone, composition-root- -/// agnostic renderer — nothing in AcDream.App/UI/Layout/ or -/// RetailUiRuntime.cs references it yet. +/// NOT wired here (CC6b page-mount half, after CC4 merges per the +/// campaign's parallelism contract): mounting into the authored +/// Appearance/Summary viewport ids (0x100003bb / 0x10000406) +/// and binding the spin/color-wheel/rotate/zoom widgets to +/// // +/// . This class is a standalone, +/// composition-root-agnostic renderer — nothing in +/// AcDream.App/UI/Layout/ or RetailUiRuntime.cs references it +/// yet. /// /// /// -/// Register row (staged deviation, retired by CC6b): retail plays a -/// live 30fps idle loop in the preview -/// (gmCG3DView's m_didAnimation/m_didAnimArray, -/// set_sequence_animation, distinct from the STATIC -/// m_didAnimationRest this class's entity builder uses). CC6a holds -/// the static rest-pose final frame only — see -/// docs/architecture/retail-divergence-register.md. +/// CC6b (pre-mount half): the preview now HAS a real live idle loop +/// (, retail's m_didAnimation DID +/// at 30fps via set_sequence_animation) instead of the CC6a-only held +/// rest pose — TS-83 is retired. still accepts a +/// static WorldEntity for callers that only want +/// ChargenPreviewEntityBuilder.TryBuild's unchanged rest-pose +/// snapshot; a caller that wants the animated preview constructs a +/// from +/// ChargenPreviewEntityBuilder.TryBuildAnimated and passes its +/// Entity here once — the animator mutates that SAME entity's +/// MeshRefs in place every Tick, and Render reads it +/// fresh (no re-SetPreview needed per frame; see +/// WorldEntity.MeshRefs's own "mutable so the animation tick can +/// replace it each frame" doc comment). /// /// internal sealed class ChargenPreviewRenderer : diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs new file mode 100644 index 00000000..4533d606 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -0,0 +1,114 @@ +using System.Numerics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.App.Rendering; + +/// +/// Retail's toggle direction enum +/// (gmBarberUI::ERotateDirection/gmCGAppearancePage::ERotateDirection +/// typedef alias, acclient.h:6848-6852,6960): Invalid=0, +/// Clockwise=1, CounterClockwise=2. +/// +internal enum ChargenRotateDirection +{ + Invalid = 0, + Clockwise = 1, + CounterClockwise = 2, +} + +/// +/// Presentation-free port of gmCGAppearancePage::Rotate +/// (0x0047CB50) + DoRotation (0x0047CA80) — the +/// button-toggled continuous rotation retail applies to the preview +/// CHARACTER's heading (CPhysicsObj::set_heading inside +/// gmCG3DView::Update, pseudo-C ~0x0047eecf1), not the camera (see +/// 's own doc comment on why rotation +/// lives here instead). Retail drives once per frame from +/// a global-message-3 tick while is set +/// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the +/// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise +/// buttons to and the render loop to . +/// +internal sealed class ChargenPreviewRotationController +{ + /// + /// Rotate's explicit sentinel write + /// (this->m_dLastRotateTime = -1.0, pseudo-C ~0x0047cba7/0x0047cbb1 + /// — the high dword 0xbff00000 paired with a zero low dword is the + /// exact IEEE-754 bit pattern for -1.0) — invalidates the + /// timestamp so the very next resets it to "now" + /// (a zero-length first delta) instead of computing a huge jump from a + /// stale or never-set value. + /// + private const double InvalidTimeSentinel = -1.0; + + private double _lastRotateTime = InvalidTimeSentinel; + private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid; + private bool _rotating; + + public bool IsRotating => _rotating; + public ChargenRotateDirection Direction => _direction; + + /// Retail's m_fCurHeading, degrees, ctor default 0 — + /// applied to the preview entity via MoveToMath.SetHeading + /// (CPhysicsObj::set_heading's exact port). + public float HeadingDegrees { get; private set; } + + /// + /// gmCGAppearancePage::Rotate @ 0x0047CB50: pressing the SAME + /// direction a second time while already rotating STOPS rotation + /// (retail's button-toggle UX); any other press (opposite direction, or + /// starting from stopped) sets that direction and (re)starts, + /// invalidating m_dLastRotateTime per this class's own sentinel + /// doc. + /// + public void Toggle(ChargenRotateDirection direction) + { + if (_rotating && direction == _direction) + { + _rotating = false; + return; + } + _direction = direction; + _lastRotateTime = InvalidTimeSentinel; + _rotating = true; + } + + /// + /// gmCGAppearancePage::DoRotation @ 0x0047CA80: per-tick + /// deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) + /// * 360, added for + /// and subtracted for every other direction (pseudo-C ~0x0047cacd: + /// if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else + /// heading += delta;), then a SINGLE-PASS clamp back into + /// [0, 360) — not a full modulo loop; retail's own tail only + /// adds/subtracts 360 once (pseudo-C ~0x0047caf3-0x0047cb31), which is + /// exactly enough for any realistic per-frame delta and is reproduced + /// here verbatim rather than "improved" into a `%=`. + /// + public void Tick(double now) + { + if (!_rotating) + return; + if (_lastRotateTime <= 0d) + _lastRotateTime = now; + + double deltaDegrees = ((now - _lastRotateTime) / ChargenPreviewCamera.RotationSecondsPerRevolution) * 360.0; + HeadingDegrees = _direction == ChargenRotateDirection.Clockwise + ? HeadingDegrees + (float)deltaDegrees + : HeadingDegrees - (float)deltaDegrees; + + if (HeadingDegrees < 0f) + HeadingDegrees += 360f; + if (HeadingDegrees > 360f) + HeadingDegrees -= 360f; + + _lastRotateTime = now; + } + + /// CPhysicsObj::set_heading's exact quaternion + /// construction — the SAME shared Core primitive retail movement already + /// ports (). + public Quaternion ToOrientation() => + MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees); +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs new file mode 100644 index 00000000..b6240024 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs @@ -0,0 +1,135 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Presentation-free port of gmCGAppearancePage::ZoomIn/ZoomOut +/// (0x0047CF00/0x0047D050) and DoZoomAnimation +/// (0x0047C960): a linear 0.6s tween of the preview camera's eye +/// between (zoomed IN) +/// and (zoomed OUT), +/// driving the SAME zoom-state swap the +/// button presses trigger in retail — immediately, not once the tween +/// finishes (see 's own doc comment). +/// +/// +/// Retail drives once per frame from a global-message-3 +/// tick while m_bShouldZoomAnimate is set +/// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the +/// CC6b page-mount half will bind the Zoom In/Out buttons to +/// / and the render loop to +/// . Direction is always (0,0,0) for this camera +/// (see 's own remarks), so only the eye +/// position tweens — retail's own m_vectCurDirection lerp is a no-op +/// here and is not reproduced. +/// +/// +internal sealed class ChargenPreviewZoomController +{ + /// + /// ZoomIn/ZoomOut's explicit invalidation write + /// (this->m_dAnimDuration = -0.1, pseudo-C ~0x0047cff1/0x0047cffb + /// and ~0x0047d12c/0x0047d136 — the exact IEEE-754 bit pattern for + /// -0.1) so the very next resets the duration + /// to and the + /// start time to "now", matching DoZoomAnimation's own + /// reset-if-invalid guard exactly. + /// + private const double InvalidDurationSentinel = -0.1; + + private readonly uint _heritageId; + private Vector3 _startEye; + private Vector3 _targetEye; + private double _animStartTime; + private double _animDuration; + private bool _shouldAnimate; + private bool _zoomedIn; + + public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera) + { + ArgumentNullException.ThrowIfNull(camera); + _heritageId = heritageId; + Camera = camera; + } + + public ChargenPreviewCamera Camera { get; } + + /// Mirrors retail's m_bZoomedIn — false (not zoomed in) + /// is the ctor-implicit default, matching 's + /// own default (see that class's doc comment for the shared citation). + public bool IsZoomedIn => _zoomedIn; + + /// + /// gmCGAppearancePage::ZoomIn @ 0x0047CF00: no-op if already + /// zoomed in (retail's own early-return guard). Otherwise starts a tween + /// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage + /// profile and swaps to the frozen rest + /// pose IMMEDIATELY (gmCG3DView::StopAnimation's call site, + /// pseudo-C ~0x0047d024, precedes the tween's own completion by + /// definition — it runs once, synchronously, inside ZoomIn + /// itself). + /// + public void ZoomIn(ChargenPreviewAnimator? animator) + { + if (_zoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId)); + _zoomedIn = true; + animator?.SetZoomedIn(true); + } + + /// + /// gmCGAppearancePage::ZoomOut @ 0x0047D050: no-op if not + /// currently zoomed in. Otherwise starts a tween toward the zoomed-OUT + /// per-heritage profile and swaps back to + /// the playing idle loop immediately, mirroring . + /// + public void ZoomOut(ChargenPreviewAnimator? animator) + { + if (!_zoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId)); + _zoomedIn = false; + animator?.SetZoomedIn(false); + } + + private void StartTween(Vector3 targetEye) + { + _startEye = Camera.Eye; + _targetEye = targetEye; + _shouldAnimate = true; + _animDuration = InvalidDurationSentinel; + } + + /// + /// gmCGAppearancePage::DoZoomAnimation @ 0x0047C960: a LINEAR + /// (not eased) lerp of the eye position from m_vectStartPosition + /// to m_vectTargPosition over + /// , clamping + /// t to exactly 1.0 (and clearing m_bShouldZoomAnimate) the + /// tick that reaches or passes the duration — the decomp shows a + /// straight (targ - start) * t + start per axis with no easing + /// curve applied anywhere in this function. + /// + public void Tick(double now) + { + if (!_shouldAnimate) + return; + + if (_animDuration <= 0d) + { + _animDuration = ChargenPreviewCamera.ZoomTweenDurationSeconds; + _animStartTime = now; + } + + double elapsed = now - _animStartTime; + if (elapsed >= _animDuration) + { + _shouldAnimate = false; + elapsed = _animDuration; + } + + float t = (float)(elapsed / _animDuration); + Camera.Eye = Vector3.Lerp(_startEye, _targetEye, t); + } +} diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index a808e483..b2502a1a 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -335,29 +335,11 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator /// /// Retail gmPaperDollUI resolves its held pose with - /// DBCache::GetDIDFromEnumStatic(0x10000005, 7). The master map - /// therefore resolves key 7 to a sub-map, then key 0x10000005 to the - /// Animation DID. + /// DBCache::GetDIDFromEnumStatic(0x10000005, 7) — + /// parameterized by the + /// paperdoll's own fixed enum key. /// - private uint ResolvePoseDid() - { - uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; - if (masterDid == 0 - || !_dats.Portal.TryGet( - masterDid, - out var master) - || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) - || !_dats.Portal.TryGet( - subDid, - out var sub)) - { - return 0u; - } - - return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did) - ? did - : 0u; - } + private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u); public void Apply(WorldEntity doll, uint setupId) { @@ -392,9 +374,7 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator orientation = frame.Frames[index].Orientation; } - Matrix4x4 transform = Matrix4x4.CreateScale(scale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); + Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation); MeshRef source = doll.MeshRefs[index]; reposed.Add(new MeshRef(source.GfxObjId, transform) { diff --git a/src/AcDream.App/Rendering/RetailHeldPose.cs b/src/AcDream.App/Rendering/RetailHeldPose.cs new file mode 100644 index 00000000..c67efe57 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailHeldPose.cs @@ -0,0 +1,61 @@ +using System.Numerics; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Shared primitives behind retail's "resolve a rest-pose DID via master-map +/// slot 7, load its Animation, hold the final frame" algorithm — the +/// mechanism (paperdoll, +/// gmPaperDollUI::RedressCreature @ 0x004A3C22) and +/// (chargen preview, +/// gmCG3DView::StopAnimation @ 0x004EE640) both implement. Extracted +/// per the CC6a review's F11/F12 note ("before adding a FOURTH consumer... a +/// shared RetailHeldPose helper is worth extracting before a fourth +/// held-pose consumer exists") — CC6b's own idle-loop work makes chargen's +/// implementation grow enough that mechanically sharing the two primitives +/// BOTH sites already had byte-identical (DID resolution, final-frame +/// transform composition) is a clean win without forcing the two sites' +/// slightly different per-index LOOP shapes (paperdoll walks an +/// already-built, already-filtered WorldEntity.MeshRefs; chargen +/// walks the pre-filter, Setup-part-indexed scratch list) into one method +/// they don't actually share. +/// +internal static class RetailHeldPose +{ + /// + /// DBCache::GetDIDFromEnumStatic(poseEnum, 7) equivalent: master + /// map → slot 7's sub-map → 's Animation DID. + /// Returns 0 if any link in the chain is missing. MUST be called under + /// the caller's dat lock (see 's + /// datLock doc — DatCollection is not thread-safe). + /// + public static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum) + { + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; + if (masterDid == 0 + || !dats.Portal.TryGet(masterDid, out var master) + || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) + || !dats.Portal.TryGet(subDid, out var sub)) + { + return 0u; + } + + return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u; + } + + /// + /// Retail's per-part pose transform: Scale(defaultScale) * + /// Rotate(orientation) * Translate(origin) — the SAME composition + /// both RetailPaperdollPoseApplicator.Apply and + /// 's pose steps use, whether + /// the (origin, orientation) pair comes from a held final frame or an + /// interpolated idle-cycle frame. + /// + public static Matrix4x4 ComposePartTransform(Vector3 defaultScale, Vector3 origin, Quaternion orientation) => + Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs index d2fa1d29..c7629130 100644 --- a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs +++ b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs @@ -12,14 +12,20 @@ namespace AcDream.Core.CharGen; /// The body Setup dat id (0x02......) to build the preview mesh from — /// gender.SetupId, overridden by the selected hair style's /// AlternateSetup when it is neither 0 nor retail's INVALID_DID -/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), falling back -/// to when the resolved -/// id is 0 OR INVALID_DID (retail: CharGenState::GetSetupID @ -/// 0x005C5B22 and gmCG3DView::Update's own check at -/// ~0x004EEA51/0x004EEA5F both test against INVALID_DID, not zero — -/// acclient.h:39909 types the field as IDClass, whose "unset" -/// value is 0xFFFFFFFF; CPhysicsObj::makeObject(setupId)'s own -/// HUMAN_SETUP_ID fallback, gmCG3DView ctor pseudo-C ~0x004EE79D). +/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), in turn +/// overridden outright by 's +/// own alternateSetupIdOverride parameter when THAT is not +/// INVALID_DID (gmCG3DView::Update's own +/// m_alternateSetupID resolution, ~0x004EEA46-0x004EEA53 — see that +/// parameter's doc for why chargen's own Appearance page never actually sets +/// it), falling back to +/// when the resolved id is STILL 0 OR INVALID_DID after all three +/// tiers (retail: CharGenState::GetSetupID @ 0x005C5B22 and +/// gmCG3DView::Update's own check at ~0x004EEA5F both test against +/// INVALID_DID, not zero — acclient.h:39909 types the field as +/// IDClass, whose "unset" value is 0xFFFFFFFF; +/// CPhysicsObj::makeObject(setupId)'s own HUMAN_SETUP_ID fallback, +/// gmCG3DView ctor pseudo-C ~0x004EE79D). /// /// /// gender.BasePaletteId (retail Sex_CG.BasePalette) — the @@ -136,6 +142,30 @@ public static class ChargenAppearanceFactory /// contribution is skipped, matching retail's own "hash miss → no-op, /// caller never checks BuildObjDesc's return value" behavior. /// + /// + /// Retail's SECOND body-Setup-override source — gmCG3DView's + /// m_alternateSetupID field (default INVALID_DID, read at + /// gmCG3DView::Update @ ~0x004EEA46-0x004EEA53) — which, when set + /// to anything other than INVALID_DID, REPLACES the hairstyle/ + /// gender-resolved Setup id outright rather than combining with it. + /// Decomp-verified NOT to be a character-creation-time mechanism: + /// every write site for m_alternateSetupID (the Penumbraen-crown + /// and Undead-no-flame variants, ~0x004DFB3F/0x004E0C54/0x004E0D42/ + /// 0x004E0DB1) lives on gmBarberUI — the POST-CREATION barber- + /// shop appearance-editing screen, a wholly separate UI class from + /// character creation's gmCGAppearancePage, which has no + /// m_pOption1Checkbox-equivalent field and never writes + /// m_alternateSetupID anywhere in its own methods (confirmed + /// against every field on gmCGAppearancePage, + /// acclient.h:56373-56428). For chargen's own preview, + /// m_alternateSetupID is therefore ALWAYS INVALID_DID in + /// retail, and this parameter's default () + /// reproduces that exactly — a real, decomp-verified precedence tier is + /// threaded through so a future non-chargen consumer of this same + /// factory (e.g. a barber-shop feature, out of Campaign CC's scope) can + /// supply one, without inventing a UI source chargen's own Appearance + /// page doesn't have. + /// public static bool TryCompose( ChargenOptions options, uint heritageId, @@ -143,7 +173,8 @@ public static class ChargenAppearanceFactory ChargenAppearanceSelection selection, IChargenPalSetSource palSets, IChargenClothingTableSource clothingTables, - out ChargenAppearanceResult result) + out ChargenAppearanceResult result, + uint alternateSetupIdOverride = InvalidDid) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(palSets); @@ -170,6 +201,14 @@ public static class ChargenAppearanceFactory if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid) setupId = hairStyle.AlternateSetup; } + + // gmCG3DView::Update @ ~0x004EEA46-0x004EEA53: m_alternateSetupID, + // when set, REPLACES the hairstyle/gender-resolved id outright — it + // does not combine with it. See alternateSetupIdOverride's own doc + // for why chargen's own Appearance page never actually supplies one. + if (alternateSetupIdOverride != InvalidDid) + setupId = alternateSetupIdOverride; + if (setupId == 0 || setupId == InvalidDid) setupId = HumanSetupId; diff --git a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs new file mode 100644 index 00000000..b9a004ff --- /dev/null +++ b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs @@ -0,0 +1,123 @@ +using System; +using System.Numerics; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Physics; + +/// +/// Retail's simplest animation-clip playback shape: advance a frame position +/// at a fixed framerate and wrap it back into [LowFrame, HighFrame], +/// then linearly interpolate one part's origin/orientation between the two +/// bracketing frames. This is the effect of +/// CPhysicsObj::set_sequence_animation (0x0050F6F0) when called +/// with a constant DID and a nonzero framerate and no further motion-command +/// traffic — e.g. gmCG3DView::StartAnimation (0x004EE600), +/// which plays the chargen preview's idle DID at a flat 30 fps with no +/// transitional blending. +/// +/// +/// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as +/// an inline, App-layer-only implementation for the "legacy" (no +/// ) NPC idle-cycle path — +/// LiveEntityAnimationPresenter.Present's non-sequencer branch +/// (CurrFrame += legacyAdvanceSeconds * Framerate with the same +/// modulo wrap) and its private TryResolvePartFrame helper (the same +/// frame-bracket lerp/slerp). That call site has a live entity, a +/// LiveEntityRuntime membership, and per-tick elapsed time supplied by +/// the render loop; the chargen preview has none of that (there is no live +/// entity — character creation hasn't happened yet), so it cannot reuse that +/// class directly. Rather than re-typing the same formula a second time, +/// this Core, pure, unit-testable class is the shared primitive: the +/// chargen preview (AcDream.App.Rendering.ChargenPreviewAnimator) +/// consumes it directly, and it is safe for a future pass to redirect +/// LiveEntityAnimationPresenter's inline copy through it as a +/// behavior-preserving mechanical follow-up (not done here — that file is +/// live, heavily tested production entity-rendering code with zero relation +/// to this preview-only feature, so touching it is out of this slice's +/// blast radius by design, not oversight). +/// +/// +public static class RetailAnimationCyclePlayback +{ + /// + /// Advances by elapsedSeconds * framerate + /// and wraps it back into [lowFrame, highFrame] with the SAME modulo + /// shape LiveEntityAnimationPresenter.Present's legacy branch uses + /// (over % (span + 1), not a plain clamp — a frame position that + /// overshoots the end by more than one span wraps around more than once + /// rather than sticking at the boundary, matching a long stall/resume). + /// Returns unchanged for a degenerate cycle + /// ( <= ), a + /// non-positive , or a non-positive + /// . + /// + public static float Advance( + float currFrame, + int lowFrame, + int highFrame, + float framerate, + float elapsedSeconds) + { + int span = highFrame - lowFrame; + if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f) + return currFrame; + + float next = currFrame + elapsedSeconds * framerate; + if (next > highFrame) + { + float over = next - lowFrame; + next = lowFrame + (over % (span + 1)); + } + else if (next < lowFrame) + { + next = lowFrame; + } + return next; + } + + /// + /// Resolves part 's origin/orientation at + /// by linearly interpolating (lerp origin, + /// slerp orientation) between the frame at floor(currFrame) and + /// the next frame in the cycle (wrapping +1 + /// back to ). Returns false — with + /// default outputs — when is outside + /// the bracketing frame's part list, matching + /// LiveEntityAnimationPresenter.TryResolvePartFrame's no- + /// sequence-frames branch exactly. + /// + public static bool TryInterpolatePart( + Animation animation, + float currFrame, + int lowFrame, + int highFrame, + int partIndex, + out Vector3 origin, + out Quaternion orientation) + { + ArgumentNullException.ThrowIfNull(animation); + + int frameIndex = (int)MathF.Floor(currFrame); + if (frameIndex < lowFrame || frameIndex > highFrame || frameIndex >= animation.PartFrames.Count) + frameIndex = lowFrame; + int nextIndex = frameIndex + 1; + if (nextIndex > highFrame || nextIndex >= animation.PartFrames.Count) + nextIndex = lowFrame; + float t = Math.Clamp(currFrame - frameIndex, 0f, 1f); + + var frames = animation.PartFrames[frameIndex].Frames; + var nextFrames = animation.PartFrames[nextIndex].Frames; + if (partIndex < frames.Count) + { + var first = frames[partIndex]; + var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; + origin = Vector3.Lerp(first.Origin, next.Origin, t); + orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); + return true; + } + + origin = default; + orientation = default; + return false; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs new file mode 100644 index 00000000..b1dc8913 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewAnimatorTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Hand-built-fixture tests for — no dat +/// access needed, since a can be +/// constructed entirely in memory. Installed-DAT coverage for the RESOLUTION +/// half (ChargenPreviewEntityBuilder.TryBuildAnimated actually finding +/// the idle DID against real dat data) lives in +/// ChargenPreviewEntityBuilderTests. +/// +public sealed class ChargenPreviewAnimatorTests +{ + private static Animation MakeTwoFrameAnim(Vector3 frame0Origin, Vector3 frame1Origin) + { + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = frame0Origin, Orientation = Quaternion.Identity }); + var pf1 = new AnimationFrame(1); + pf1.Frames.Add(new Frame { Origin = frame1Origin, Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + anim.PartFrames.Add(pf1); + return anim; + } + + private static ChargenPreviewAnimatedBuild MakeBuild(Animation? idleAnimation, int idleLow = 0, int idleHigh = 1) + { + const uint gfxObjId = 0x0100_0001u; + var restMeshRefs = new List + { + new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))), // distinct from any idle frame, so tests can tell them apart. + }; + var drawableParts = new List + { + new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null), + }; + var entity = new WorldEntity + { + Id = ChargenPreviewEntityBuilder.PreviewRenderId, + ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = restMeshRefs, + }; + return new ChargenPreviewAnimatedBuild + { + Entity = entity, + DrawableParts = drawableParts, + RestMeshRefs = restMeshRefs, + IdleAnimation = idleAnimation, + IdleLowFrame = idleLow, + IdleHighFrame = idleHigh, + }; + } + + [Fact] + public void Constructor_WithIdleAnimation_SeedsFrameZeroPose_NotTheRestPose() + { + // Retail's true default is the idle loop PLAYING, not the rest pose + // — see ChargenPreviewEntityBuilder's class doc. + var origin0 = new Vector3(1f, 0f, 0f); + var origin1 = new Vector3(5f, 0f, 0f); + var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1)); + + var animator = new ChargenPreviewAnimator(build); + + Assert.False(animator.IsZoomedIn); + Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void Constructor_WithNoIdleAnimation_KeepsTheRestPoseFallback() + { + var build = MakeBuild(idleAnimation: null); + + var animator = new ChargenPreviewAnimator(build); + + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void Tick_AdvancesTheIdleFrame_InterpolatingBetweenFrames() + { + var origin0 = new Vector3(0f, 0f, 0f); + var origin1 = new Vector3(10f, 0f, 0f); + var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1)); + var animator = new ChargenPreviewAnimator(build); + + // 30fps, half a frame's worth of elapsed time -> currFrame 0.5, lerp halfway. + animator.Tick(1f / 60f); + + Assert.Equal(5f, animator.Entity.MeshRefs[0].PartTransform.Translation.X, 3); + } + + [Fact] + public void SetZoomedIn_True_SwapsToTheFrozenRestPoseImmediately() + { + var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f))); + var animator = new ChargenPreviewAnimator(build); + + animator.SetZoomedIn(true); + + Assert.True(animator.IsZoomedIn); + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void Tick_WhileZoomedIn_DoesNotAdvanceTheFrozenPose() + { + var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f))); + var animator = new ChargenPreviewAnimator(build); + animator.SetZoomedIn(true); + + animator.Tick(10f); // large elapsed time — must still be a no-op while zoomed in. + + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void SetZoomedIn_False_RestartsTheIdleLoopAtFrameZero() + { + var origin0 = new Vector3(1f, 0f, 0f); + var origin1 = new Vector3(5f, 0f, 0f); + var build = MakeBuild(MakeTwoFrameAnim(origin0, origin1)); + var animator = new ChargenPreviewAnimator(build); + + animator.Tick(1f / 30f); // advance to frame 1. + animator.SetZoomedIn(true); + animator.SetZoomedIn(false); // gmCG3DView::StartAnimation restarts the clip (clear-then-append). + + Assert.Equal(origin0, animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void SetZoomedIn_SameStateTwice_IsANoOp() + { + var build = MakeBuild(MakeTwoFrameAnim(new Vector3(1f, 0f, 0f), new Vector3(5f, 0f, 0f))); + var animator = new ChargenPreviewAnimator(build); + + animator.Tick(1f / 60f); // partway through frame 0->1. + Vector3 beforeX = animator.Entity.MeshRefs[0].PartTransform.Translation; + animator.SetZoomedIn(false); // already not zoomed in — must not restart the loop. + + Assert.Equal(beforeX, animator.Entity.MeshRefs[0].PartTransform.Translation); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs index 656b27eb..4e59b9a5 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -124,4 +124,155 @@ public sealed class ChargenPreviewEntityBuilderTests Assert.NotNull(entity); Assert.NotEmpty(entity!.MeshRefs); } + + /// + /// CC6b: TryBuildAnimated resolves a real idle Animation (retail's + /// m_didAnimation) against the installed EoR dat, with a usable + /// frame range and a non-empty drawable-part list a + /// ChargenPreviewAnimator can drive. + /// + [Fact] + public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); + Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)); + + var catalog = new ChargenAppearanceCatalog(adapter); + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + var animations = new RetailAnimationLoader(adapter); + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object()); + + Assert.NotNull(build); + Assert.NotEmpty(build!.DrawableParts); + Assert.NotEmpty(build.RestMeshRefs); + Assert.NotNull(build.IdleAnimation); + Assert.True(build.IdleHighFrame >= build.IdleLowFrame); + Assert.True(build.IdleAnimation!.PartFrames.Count > build.IdleHighFrame); + + // Live end-to-end: an Animator built from this resolves a non-empty, + // playable preview — retail's true default (idle playing), not the + // frozen rest pose TryBuild alone still returns. + var animator = new ChargenPreviewAnimator(build); + Assert.False(animator.IsZoomedIn); + Assert.NotEmpty(animator.Entity.MeshRefs); + + animator.Tick(1f / 30f); // one frame's worth — must not throw or empty the mesh. + Assert.NotEmpty(animator.Entity.MeshRefs); + } + + [Fact] + public void TryBuildAnimated_UnknownSetupId_ReturnsNull() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var animations = new RetailAnimationLoader(adapter); + + var bogusAppearance = new ChargenAppearanceResult( + SetupId: 0x0200_FFFFu, + BasePaletteId: 0u, + ObjDesc: ChargenObjDesc.Empty, + MissingPalSetIds: [], + MissingClothingTableIds: [], + ClothingTablesMissingBaseEffectForSetup: []); + + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object()); + + Assert.Null(build); + } + + /// + /// Decomp-verified quirk (gmCG3DView's ctor / ::Update, + /// pseudo-C ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8): Olthoi + /// and OlthoiAcid use the SAME enum key (0x10000011 / 0x10000013) for + /// BOTH the live idle DID (m_didAnimation) and the rest DID + /// (m_didAnimationRest) — every standard heritage uses two + /// DIFFERENT keys (0x10000006 idle vs 0x10000005 rest). This proves the + /// SHARED enum key resolves to a real installed Animation DID (the same + /// RetailHeldPose.ResolvePoseDid call + /// ChargenPreviewEntityBuilder's ResolveIdleAnimEnum AND + /// ResolveRestPoseEnum both return for these two heritages) — the + /// enum-key identity itself is source-verified (both private methods + /// literally return the SAME numeric constant for Olthoi/OlthoiAcid, see + /// their own doc comments), so a single resolution here is enough to + /// confirm the shared key is not a dead/unresolvable id. + /// + [Theory] + [InlineData(0x10000011u)] // Olthoi's shared idle/rest enum key. + [InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key. + public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey) + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + uint did = RetailHeldPose.ResolvePoseDid(adapter, sharedEnumKey); + + Assert.NotEqual(0u, did); + Assert.Equal(0x03u, did >> 24); // resolves to a real Animation DID. + } + + /// + /// Extends + /// to the idle side: TryBuildAnimated resolves a real idle + /// Animation for Olthoi too (not just the rest pose the older + /// TryBuild-only test covers), so an Olthoi + /// ChargenPreviewAnimator actually plays instead of silently + /// falling back to the rest-only pose. + /// + [Fact] + public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi)); + Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male) + || olthoi.GendersByKey.TryGetValue(2, out male)); + Assert.NotNull(male); + int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key; + + var catalog = new ChargenAppearanceCatalog(adapter); + var animations = new RetailAnimationLoader(adapter); + bool composed = ChargenAppearanceFactory.TryCompose( + options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 }, + catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object()); + + Assert.NotNull(build); + Assert.NotNull(build!.IdleAnimation); + + var animator = new ChargenPreviewAnimator(build); + Assert.False(animator.IsZoomedIn); + Assert.NotEmpty(animator.Entity.MeshRefs); + } } diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs new file mode 100644 index 00000000..358cf051 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs @@ -0,0 +1,117 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Pure (no dat access) tests for +/// — the port of gmCGAppearancePage::Rotate/DoRotation +/// (0x0047CB50/0x0047CA80). +/// +public sealed class ChargenPreviewRotationControllerTests +{ + [Fact] + public void Toggle_StartsRotatingInTheGivenDirection() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + + Assert.True(controller.IsRotating); + Assert.Equal(ChargenRotateDirection.Clockwise, controller.Direction); + } + + [Fact] + public void Toggle_SameDirectionWhileRotating_Stops() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Toggle(ChargenRotateDirection.Clockwise); + + Assert.False(controller.IsRotating); + } + + [Fact] + public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Toggle(ChargenRotateDirection.CounterClockwise); + + Assert.True(controller.IsRotating); + Assert.Equal(ChargenRotateDirection.CounterClockwise, controller.Direction); + } + + [Fact] + public void Tick_WhileNotRotating_IsANoOp() + { + var controller = new ChargenPreviewRotationController(); + controller.Tick(100.0); + + Assert.Equal(0f, controller.HeadingDegrees); + } + + [Fact] + public void Tick_FirstCallAfterToggle_ContributesZeroDelta() + { + // Rotate() invalidates m_dLastRotateTime so the very first DoRotation + // tick resets it to "now" rather than computing a huge jump from a + // stale/never-set timestamp. + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(1000.0); + + Assert.Equal(0f, controller.HeadingDegrees); + } + + [Fact] + public void Tick_ClockwiseAdvance_AddsTheExactPerTickFormula() + { + // deltaDegrees = ((now - last) / RotationSecondsPerRevolution) * 360. + // Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid + // guard, same as retail's own sentinel check would if Timer::cur_time + // could ever read exactly zero — never in practice, so tests avoid + // it too). + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. + controller.Tick(11.5); // half a revolution at 3 s/rev. + + Assert.Equal(180f, controller.HeadingDegrees, 3); + } + + [Fact] + public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.CounterClockwise); + controller.Tick(10.0); + controller.Tick(11.5); // would go to -180, wraps to +180. + + Assert.Equal(180f, controller.HeadingDegrees, 3); + } + + [Fact] + public void Tick_AccumulatesAcrossMultipleTicks() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(10.0); + controller.Tick(10.5); // +60 deg. + controller.Tick(11.0); // +60 deg more. + + Assert.Equal(120f, controller.HeadingDegrees, 3); + } + + [Fact] + public void ToOrientation_AtZeroHeading_IsIdentity() + { + var controller = new ChargenPreviewRotationController(); + Quaternion orientation = controller.ToOrientation(); + + Assert.Equal(Quaternion.Identity.X, orientation.X, 4); + Assert.Equal(Quaternion.Identity.Y, orientation.Y, 4); + Assert.Equal(Quaternion.Identity.Z, orientation.Z, 4); + Assert.Equal(Quaternion.Identity.W, orientation.W, 4); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs new file mode 100644 index 00000000..b5061af5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs @@ -0,0 +1,166 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Pure (no dat access) tests for +/// — the port of gmCGAppearancePage::ZoomIn/ZoomOut/ +/// DoZoomAnimation (0x0047CF00/0x0047D050/0x0047C960) +/// including its immediate wiring into 's +/// idle-loop ↔ rest-pose swap. +/// +public sealed class ChargenPreviewZoomControllerTests +{ + private static ChargenPreviewAnimator MakeAnimator() + { + const uint gfxObjId = 0x0100_0001u; + var restMeshRefs = new System.Collections.Generic.List + { + new(gfxObjId, Matrix4x4.CreateTranslation(new Vector3(99f, 99f, 99f))), + }; + var drawableParts = new System.Collections.Generic.List + { + new(SetupPartIndex: 0, GfxObjId: gfxObjId, DefaultScale: Vector3.One, SurfaceOverrides: null), + }; + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + var entity = new WorldEntity + { + Id = ChargenPreviewEntityBuilder.PreviewRenderId, + ServerGuid = ChargenPreviewEntityBuilder.PreviewServerGuid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = restMeshRefs, + }; + var build = new ChargenPreviewAnimatedBuild + { + Entity = entity, + DrawableParts = drawableParts, + RestMeshRefs = restMeshRefs, + IdleAnimation = anim, + IdleLowFrame = 0, + IdleHighFrame = 0, + }; + return new ChargenPreviewAnimator(build); + } + + [Fact] + public void ZoomIn_StartsATweenTowardTheDefaultEye_AndMarksZoomedIn() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + + controller.ZoomIn(animator: null); + + Assert.True(controller.IsZoomedIn); + // Tween in progress — eye hasn't jumped yet (Tick hasn't run). + Assert.Equal(ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian), camera.Eye); + } + + [Fact] + public void ZoomIn_WhileAlreadyZoomedIn_IsANoOp() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + controller.ZoomIn(animator: null); // real tween: zoomed-out eye -> default eye. + controller.Tick(10.0); + controller.Tick(10.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds + 1.0); // fully complete it. + Vector3 eyeAfterCompletion = camera.Eye; + + controller.ZoomIn(animator: null); // second call — retail's own early-return guard. + controller.Tick(9999.0); // if ZoomIn wrongly armed a tween, this would move the eye. + + Assert.Equal(eyeAfterCompletion, camera.Eye); + } + + [Fact] + public void ZoomOut_WhileNotZoomedIn_IsANoOp() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + Vector3 startEye = camera.Eye; + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + + controller.ZoomOut(animator: null); + + Assert.False(controller.IsZoomedIn); + Assert.Equal(startEye, camera.Eye); + } + + [Fact] + public void Tick_LinearlyInterpolatesTheEye_HalfwayAtHalfTheDuration() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + Vector3 startEye = camera.Eye; // ctor default == the zoomed-IN eye. + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + controller.ZoomIn(animator: null); // reach the "zoomed in" state (zero-distance tween — Eye already there). + Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + controller.ZoomOut(animator: null); // NOW arms a real tween: default eye -> zoomed-out eye. + + controller.Tick(100.0); // seeds the tween's own start time (first tick of a fresh -0.1 sentinel). + controller.Tick(100.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds / 2.0); + + Vector3 expectedHalfway = Vector3.Lerp(startEye, targetEye, 0.5f); + Assert.Equal(expectedHalfway.X, camera.Eye.X, 3); + Assert.Equal(expectedHalfway.Y, camera.Eye.Y, 3); + Assert.Equal(expectedHalfway.Z, camera.Eye.Z, 3); + } + + [Fact] + public void Tick_PastTheFullDuration_ClampsExactlyToTheTargetEye_AndStopsAnimating() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + controller.ZoomIn(animator: null); // reach "zoomed in" (zero-distance). + Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + controller.ZoomOut(animator: null); // arms the real tween toward targetEye. + + controller.Tick(0.0); + controller.Tick(100.0); // way past the 0.6s duration. + + Assert.Equal(targetEye, camera.Eye); + + Vector3 eyeAfterCompletion = camera.Eye; + controller.Tick(200.0); // tween finished — further ticks must not move the eye. + Assert.Equal(eyeAfterCompletion, camera.Eye); + } + + [Fact] + public void ZoomIn_ImmediatelyFreezesTheAnimatorToTheRestPose_BeforeTheTweenCompletes() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + var animator = MakeAnimator(); + + controller.ZoomIn(animator); + + // No Tick() call at all — retail's ZoomIn calls StopAnimation + // synchronously, before the camera tween has advanced a single frame. + Assert.True(animator.IsZoomedIn); + Assert.Equal(new Vector3(99f, 99f, 99f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } + + [Fact] + public void ZoomOut_ImmediatelyResumesTheAnimatorsIdleLoop() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera); + var animator = MakeAnimator(); + controller.ZoomIn(animator); + + controller.ZoomOut(animator); + + Assert.False(animator.IsZoomedIn); + Assert.Equal(new Vector3(1f, 0f, 0f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs index 8f4ffa7b..cff70aa1 100644 --- a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs +++ b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs @@ -302,6 +302,86 @@ public sealed class ChargenAppearanceFactoryTests Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); } + /// + /// CC6b MUST-COVER item 4 — alternateSetupIdOverride (retail's + /// m_alternateSetupID) must WIN outright over the hairstyle's own + /// AlternateSetup when both are supplied, matching + /// gmCG3DView::Update's replace-not-combine precedence + /// (~0x004EEA46-0x004EEA53). + /// + [Fact] + public void TryCompose_AlternateSetupIdOverride_WinsOverHairStyleAlternateSetup() + { + const uint hairStyleSetup = 0x0200_00AAu; + const uint pageLevelOverride = 0x0200_00BBu; + ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: hairStyleSetup)); + var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result, + alternateSetupIdOverride: pageLevelOverride); + + Assert.Equal(pageLevelOverride, result.SetupId); + } + + /// + /// Companion: with NO hair style selected at all (so there is nothing for + /// the page-level override to out-rank), the override still replaces the + /// plain gender.SetupId. + /// + [Fact] + public void TryCompose_AlternateSetupIdOverride_WinsOverPlainGenderSetupId() + { + const uint pageLevelOverride = 0x0200_00CCu; + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(bodySetupId: pageLevelOverride); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing, + out ChargenAppearanceResult result, + alternateSetupIdOverride: pageLevelOverride); + + Assert.Equal(pageLevelOverride, result.SetupId); + } + + /// + /// The default (no override supplied) call shape is unaffected — proves + /// the new trailing parameter is additive, not a behavior change for + /// every existing caller. + /// + [Fact] + public void TryCompose_NoAlternateSetupIdOverrideSupplied_ResolvesAsBefore() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing, + out ChargenAppearanceResult result); + + Assert.Equal(BodySetupId, result.SetupId); + } + + /// + /// An override equal to retail's INVALID_DID sentinel means "no + /// override" (the field's own default), not "adopt 0xFFFFFFFF as the + /// Setup id" — same sentinel discipline as the hairstyle source (F1). + /// + [Fact] + public void TryCompose_AlternateSetupIdOverrideIsInvalidDid_IsTreatedAsNoOverride() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, pal, clothing, + out ChargenAppearanceResult result, + alternateSetupIdOverride: 0xFFFFFFFFu); + + Assert.Equal(BodySetupId, result.SetupId); + } + [Fact] public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald() { diff --git a/tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs b/tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs new file mode 100644 index 00000000..7b1987bc --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailAnimationCyclePlaybackTests.cs @@ -0,0 +1,153 @@ +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// is the shared advance-with-wrap +/// + lerp/slerp primitive behind the chargen preview's idle loop +/// (ChargenPreviewAnimator) — the SAME arithmetic +/// LiveEntityAnimationPresenter.Present's legacy (no- +/// ) branch already carries for NPC idle +/// cycles, extracted here so a second, live-entity-free consumer (the +/// chargen preview, which has no LiveEntityRuntime membership to hang +/// a sequencer off of) doesn't retype the formula. +/// +public sealed class RetailAnimationCyclePlaybackTests +{ + private static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation) + { + var anim = new Animation(); + for (int f = 0; f < numFrames; f++) + { + var pf = new AnimationFrame((uint)numParts); + for (int p = 0; p < numParts; p++) + pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation }); + anim.PartFrames.Add(pf); + } + return anim; + } + + [Fact] + public void Advance_WithinSpan_AddsElapsedTimesFramerate() + { + float result = RetailAnimationCyclePlayback.Advance( + currFrame: 5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.1f); + + Assert.Equal(8f, result, precision: 4); // 5 + 0.1*30 = 8. + } + + [Fact] + public void Advance_PastHighFrame_WrapsBackToLowFrame() + { + // 29-frame span (0..29 inclusive = 30 frames), advancing from frame + // 28 by one second at 30fps overshoots by (28+30)-29 = 29, wrapping + // to lowFrame + (29 % 30) = 29... use a case with a clean wrap. + float result = RetailAnimationCyclePlayback.Advance( + currFrame: 25f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.2f); + + // 25 + 6 = 31, over highFrame(29) by span+1=30: over = 31-0 = 31, + // wrapped = 0 + (31 % 30) = 1. + Assert.Equal(1f, result, precision: 4); + } + + [Fact] + public void Advance_BelowLowFrame_ClampsToLowFrame() + { + float result = RetailAnimationCyclePlayback.Advance( + currFrame: -5f, lowFrame: 0, highFrame: 29, framerate: 30f, elapsedSeconds: 0.05f); + + // -5 + 1.5 = -3.5, still below lowFrame(0) -> clamp. + Assert.Equal(0f, result); + } + + [Theory] + [InlineData(0, 0)] // degenerate span (highFrame == lowFrame). + [InlineData(0, -1)] // inverted span. + public void Advance_DegenerateSpan_ReturnsCurrFrameUnchanged(int lowFrame, int highFrame) + { + float result = RetailAnimationCyclePlayback.Advance( + currFrame: 3f, lowFrame, highFrame, framerate: 30f, elapsedSeconds: 1f); + + Assert.Equal(3f, result); + } + + [Fact] + public void Advance_NonPositiveFramerateOrElapsed_ReturnsCurrFrameUnchanged() + { + Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 0f, elapsedSeconds: 1f)); + Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: 0f)); + Assert.Equal(3f, RetailAnimationCyclePlayback.Advance(3f, 0, 29, framerate: 30f, elapsedSeconds: -1f)); + } + + [Fact] + public void TryInterpolatePart_ExactFrame_ReturnsThatFramesPose() + { + Animation anim = MakeAnim(3, 2, new Vector3(1f, 2f, 3f), Quaternion.Identity); + + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 1f, lowFrame: 0, highFrame: 2, partIndex: 0, + out Vector3 origin, out Quaternion orientation); + + Assert.True(ok); + Assert.Equal(new Vector3(1f, 2f, 3f), origin); + Assert.Equal(Quaternion.Identity, orientation); + } + + [Fact] + public void TryInterpolatePart_BetweenFrames_LerpsOriginHalfway() + { + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); + var pf1 = new AnimationFrame(1); + pf1.Frames.Add(new Frame { Origin = new Vector3(10f, 0f, 0f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + anim.PartFrames.Add(pf1); + + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 0.5f, lowFrame: 0, highFrame: 1, partIndex: 0, + out Vector3 origin, out _); + + Assert.True(ok); + Assert.Equal(new Vector3(5f, 0f, 0f), origin); + } + + [Fact] + public void TryInterpolatePart_AtHighFrame_WrapsNextFrameToLowFrame() + { + var anim = new Animation(); + var pf0 = new AnimationFrame(1); + pf0.Frames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity }); + var pf1 = new AnimationFrame(1); + pf1.Frames.Add(new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf0); + anim.PartFrames.Add(pf1); + + // currFrame exactly at highFrame(1): frameIndex=1, nextIndex would be + // 2 which is > highFrame -> wraps to lowFrame(0). t=0 so origin==frame[1]. + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 1f, lowFrame: 0, highFrame: 1, partIndex: 0, + out Vector3 origin, out _); + + Assert.True(ok); + Assert.Equal(new Vector3(2f, 0f, 0f), origin); + } + + [Fact] + public void TryInterpolatePart_PartIndexOutOfRange_ReturnsFalse() + { + Animation anim = MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); + + bool ok = RetailAnimationCyclePlayback.TryInterpolatePart( + anim, currFrame: 0f, lowFrame: 0, highFrame: 1, partIndex: 5, + out Vector3 origin, out Quaternion orientation); + + Assert.False(ok); + Assert.Equal(default(Vector3), origin); + Assert.Equal(default(Quaternion), orientation); + } +}