diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c9131e81..707258a9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,39 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #403 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch + +**Status:** OPEN (post-CC consolidation follow-up) +**Severity:** LOW +**Filed:** 2026-08-15 (Campaign CC slice CC6b-PRE review fix round, F5) +**Component:** `src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs`, +`src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs` + +`RetailAnimationCyclePlayback` (advance-with-wrap + lerp/slerp) is a Core, +pure, unit-tested extraction of the SAME algorithm +`LiveEntityAnimationPresenter.Present`'s legacy (no-`AnimationSequencer`) +branch already carries inline for NPC idle cycles +(`CurrFrame += legacyAdvanceSeconds * Framerate` with the same modulo wrap, +plus its own private `TryResolvePartFrame` doing the same frame-bracket +lerp/slerp). The chargen preview (`ChargenPreviewAnimator`) consumes the +new shared type; the two implementations were deliberately left +un-consolidated at CC6b-PRE — `LiveEntityAnimationPresenter` is live, +heavily-tested, in-flight production entity-rendering code with zero +relation to the preview-only feature that motivated the extraction, so +touching it was judged out of that slice's blast radius. + +That decision has no tracked owner. Someone should, in a dedicated pass +after Campaign CC closes: redirect `LiveEntityAnimationPresenter`'s inline +copy through `RetailAnimationCyclePlayback` (a behavior-preserving +mechanical swap — same formulas, same order of operations) and delete the +duplicate. Verify byte-identical output first (a differential test against +the pre-change behavior over a representative NPC idle set) before landing. + +**Acceptance:** one call site for the advance-with-wrap + lerp/slerp +algorithm; `LiveEntityAnimationPresenter`'s legacy branch calls +`RetailAnimationCyclePlayback` instead of reimplementing it; no behavior +change to any currently-animated NPC. + ## #402 — Flaky test: Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate **Status:** OPEN (flake, not a regression) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a5d5f27f..acfa8ed6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -394,10 +394,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills, Appearance (this round's placeholder), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 49 active rows (TS-82 filed 2026-08-15 at Campaign CC slice CC4 — the Appearance/Summary page roots mount empty and content-inert, reachable via free tab navigation, pending CC5/CC6a/CC6b; TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 50 active rows (TS-82 filed 2026-08-15 at Campaign CC slice CC4 — the Appearance/Summary page roots mount empty and content-inert, reachable via free tab navigation, pending CC5/CC6a/CC6b; TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | | TS-76 | Five Character-tab rows have no acdream consumer at all (research doc §4.2's own "state-only, no consumer" list, narrowed to the ids NOT already closed by Campaign OP's Group-C re-points): "Display 3D Tooltips" (`ShowTooltips`), "Side By Side Vitals" (`SideBySideVitals`), "Display Spell Durations" (`SpellDuration`), "Advanced Combat Interface" (`AdvancedCombatUI`), "Stay in Chat Mode After Sending a Message" (`StayInChatMode`) — retail renders 3D item tooltips, an alternate side-by-side vitals layout, remaining-duration overlays on enchantment icons, an expanded combat panel, and a chat-input-stays-open behavior respectively; acdream has none of the four rendering surfaces and no chat-input-close-on-send behavior to gate in the first place. | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (the rows wire+store only) | Each needs a real UI/behavior feature built before the option means anything — inventing a stand-in now would be exactly the workaround CLAUDE.md forbids. | Toggling any of the five writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes. | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; `EffectInfoRegion::Update @0x004f1c00`; `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `ChatInterface::HandleEnterKey @0x004f52d0`; `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index e76987cc..826eb0ce 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -253,6 +253,8 @@ the user gate. | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | | CC5 | — | | | | -| CC6a | — | | | | -| CC6b | — | | | | +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). + +**Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | +| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs new file mode 100644 index 00000000..b35b00d1 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs @@ -0,0 +1,157 @@ +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 DEFAULT-false claim itself rests on gmCGAppearancePage::InitializePage +/// @ 0x0047FDD0's explicit this->m_bZoomedIn = 0; at +/// 0x004802C3 — written immediately after that same function sets the +/// camera to the zoomed-IN per-heritage eye (0x00480286-0x0048029E), +/// not from the ctor simply never touching the field (heap operator new +/// memory is indeterminate, not zero — that argument doesn't hold on its +/// own). One retail quirk this implies: the character starts framed close-up +/// AND not-zoomed-in at the same time, so the FIRST Zoom In click (once +/// mounted) tweens close-eye→close-eye — visually null — while still +/// freezing the animation; the port reproduces this faithfully rather than +/// treating it as a bug. +/// +/// +/// +/// 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; + + // Double-buffered so a 30fps Tick doesn't allocate a fresh List + // every frame: one buffer is whatever Entity.MeshRefs currently points + // at (potentially still being read by the renderer's own Render() call + // for this frame), the other is safe to Clear()+refill for the NEXT + // tick and only gets published once fully populated. + private readonly List _meshRefsBufferA = []; + private readonly List _meshRefsBufferB = []; + private bool _nextBufferIsA = true; + + public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build) + { + _build = build ?? throw new ArgumentNullException(nameof(build)); + _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; + List meshRefs = _nextBufferIsA ? _meshRefsBufferA : _meshRefsBufferB; + _nextBufferIsA = !_nextBufferIsA; + meshRefs.Clear(); + 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 new file mode 100644 index 00000000..98db610d --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -0,0 +1,185 @@ +using System; +using System.Numerics; +using AcDream.Core.CharGen; + +namespace AcDream.App.Rendering; + +/// +/// Heritage-parameterized camera for the chargen 3D preview +/// (gmCG3DView, Appearance page viewport 0x100003bb / Summary +/// 0x10000406). Retail-exact eye positions, ported from +/// gmCGAppearancePage::Update @ 0x0047E8F0 (pseudo-C ~139037-139114, +/// which sets m_vectTargPosition/m_vectCurPosition per +/// heritage and snaps them together with no tween — CC6a's static preview +/// renders that snapped default, the "zoomed-in" framing) and cross-checked +/// against the IDENTICAL literals in gmCGAppearancePage::ZoomIn @ +/// 0x0047CF00 (pseudo-C ~137618-137638). Direction is always +/// (0,0,0)CreatureMode::SetCameraDirection resets the view +/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention +/// already established for the paperdoll (look +/// straight down +Y, +Z up); every camera position below is used AS the +/// world-space eye directly, matching that camera's approach. +/// +/// +/// Rotation is NOT a camera property. Retail's continuous-rotation +/// button (gmCGAppearancePage::DoRotation @ 0x0047CA80) advances a +/// HEADING applied to the preview CHARACTER (CPhysicsObj::set_heading +/// inside gmCG3DView::Update, pseudo-C ~242088) — the camera's own +/// position/direction never change during a rotation. 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 +{ + private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as DollCamera/ChaseCamera. + + private Vector3 _eye; + + public ChargenPreviewCamera(uint heritageId = 0u) + { + _eye = ResolveDefaultEye(heritageId); + } + + /// + /// The camera's current world-space eye. Settable so CC6b can react to a + /// heritage change without reconstructing the camera. + /// + public Vector3 Eye + { + get => _eye; + set => _eye = value; + } + + /// Re-derives for the given heritage id (retail's mHeritageGroup). + public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId); + + /// + /// Retail default (zoomed-in) camera eye per heritage. All four profiles + /// share X=0; only (Y, Z) — the AC world-space forward + /// offset and height — vary. FOUR distinct profiles across the 13 + /// heritages, not five: standard heritages (Aluvian, Gharu'ndim, Sho, + /// Viamontian, Shadowbound, Gearknight, Lugian, Empyrean, Penumbraen, + /// Undead — everything except Tumerok/Olthoi/OlthoiAcid) share the SAME + /// numeric offset as Gearknight's own dedicated branch in the decomp. + /// + public static Vector3 ResolveDefaultEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -1.85000002f, 1.85000002f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -3.04999995f, 2.75f), + (uint)ChargenHeritageGroup.Tumerok => new Vector3(0f, -0.850000024f, 1.64999998f), + _ => new Vector3(0f, -0.550000012f, 1.64999998f), + }; + + /// + /// Retail zoomed-OUT camera eye per heritage + /// (gmCGAppearancePage::ZoomOut @ 0x0047D050, pseudo-C + /// ~137671-137687). CC6a does not implement the zoom button (CC6b) — + /// recorded here as the verified target CC6b's tween will animate + /// toward. Olthoi/OlthoiAcid each keep their own dedicated profile; + /// every other heritage — INCLUDING Tumerok, whose zoomed-IN profile is + /// special-cased but whose zoomed-OUT is not — shares one value. + /// + public static Vector3 ResolveZoomedOutEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -3.79999995f, 1.14999998f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -5.69999981f, 1.64999998f), + _ => new Vector3(0f, -2.5f, 0.95f), + }; + + /// + /// Seconds per 360° revolution for the continuous-rotation button + /// (gmCGAppearancePage::m_dRotationPerSec, ctor pseudo-C + /// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000, + /// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly, + /// no reconstruction needed). Retail's own per-tick formula + /// (gmCGAppearancePage::DoRotation @ 0x0047CA80, pseudo-C + /// ~0x0047CAC7): deltaDegrees = ((now - lastRotateTime) / + /// RotationSecondsPerRevolution) * 360 — CC6b's rotation controller + /// consumes this constant in exactly that shape, not as a + /// degrees-per-second rate. NOT applied here; see this class's own doc + /// comment on why rotation is not a camera concern. + /// + public const float RotationSecondsPerRevolution = 3.0f; + + /// + /// Zoom tween duration in seconds + /// (gmCGAppearancePage::DoZoomAnimation @ 0x0047C960's + /// reset-if-invalid default, cross-confirmed by ZoomIn/ZoomOut's + /// own -0.1 sentinel write, which deliberately invalidates + /// m_dAnimDuration so the very next DoZoomAnimation tick + /// resets it to this same value). The campaign plan flagged this + /// constant as decompiler-garbled (both sites split the raw double + /// across two 32-bit stores, and the decompiler mis-renders the LOW + /// dword's store as a bogus float literal instead of raw bits) — it is + /// NOT unrecoverable: reinterpreting each garbled float literal as its + /// own raw 32-bit pattern and pairing it with the store's (clean) high + /// dword reconstructs an exact IEEE-754 double both times. + /// DoZoomAnimation's own reset path: low32 from + /// 4.17232506e-08f reinterpreted = 0x33333333, high32 = + /// 0x3fe33333 (clean) → exactly 0.6. Cross-check via + /// ZoomIn/ZoomOut's sentinel: low32 from + /// -1.58818684e-23f reinterpreted = 0x9999999A, high32 = + /// 0xbfb99999 (clean) → exactly -0.1, the well-known + /// IEEE-754 bit pattern for -0.1 (0xBFB999999999999A) — confirming + /// the reconstruction technique itself, not just this one value. + /// + public const float ZoomTweenDurationSeconds = 0.6f; + + public float FovRadians { get; set; } = MathF.PI / 4f; // retail CreatureMode default, same as DollCamera. + public float Near { get; set; } = 0.1f; + public float Far { get; set; } = 50f; + public float Aspect { get; set; } = 1f; + + public Matrix4x4 View => + Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Up); + + public Matrix4x4 Projection => + Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far); +} + +/// +/// Internal private-viewport adapter, mirroring DollViewportCamera's +/// role for . +/// +internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera +{ + private readonly ChargenPreviewCamera _camera; + + public ChargenPreviewViewportCamera(uint heritageId = 0u) + { + _camera = new ChargenPreviewCamera(heritageId); + } + + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public Vector3 Eye => _camera.Eye; + public float FovRadians + { + get => _camera.FovRadians; + set => _camera.FovRadians = value; + } + public float Near + { + get => _camera.Near; + set => _camera.Near = value; + } + public float Far + { + get => _camera.Far; + set => _camera.Far = value; + } + public float Aspect + { + get => _camera.Aspect; + set => _camera.Aspect = value; + } + public Matrix4x4 View => _camera.View; + public Matrix4x4 Projection => _camera.Projection; +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs new file mode 100644 index 00000000..df21c5a6 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -0,0 +1,431 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Meshing; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// 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 +/// already-resolved refs from a live entity (there is no live entity yet; +/// character creation hasn't happened). DAT-touching, unlike +/// 's pure index-agnostic builder — the +/// closest existing precedent for the actual mesh-flatten/apply-changes/ +/// resolve-surface-overrides steps is +/// DatLiveEntityProjectionMaterializer.TryMaterialize, trimmed to +/// what a private, non-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 default is +/// DIRECTLY ASSIGNED, not inherited: +/// gmCGAppearancePage::InitializePage @0x0047FDD0 writes an +/// explicit m_bZoomedIn = 0 at 0x004802C3 (right after +/// setting the camera to the zoomed-IN per-heritage eye at +/// 0x00480286-0x0048029E — the null-tween quirk the zoom +/// controller's doc records). The earlier elided-ctor-byte argument was +/// unsound (heap-new members are indeterminate, not zero) and was +/// replaced by this citation at the CC6b-PRE re-review. So retail's +/// chargen preview plays its idle loop (m_didAnimation, 30fps) from +/// the very first frame; the REST pose (m_didAnimationRest, held +/// final frame, this class's pre-CC6b-only behavior) only appears once the +/// 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 +{ + /// Reserved synthetic guid for the chargen preview clone — + /// same reserved family as + /// (0xDA11D0xx) and CreatureAppraisalEntityBuilder (0xDA11D02x). + public const uint PreviewServerGuid = 0xDA11_D031u; + + /// Reserved render-local entity id — passed in + /// animatedEntityIds by the renderer so a re-dress (a new + /// selection) bypasses WbDrawDispatcher's Tier-1 classification + /// cache, mirroring 's own + /// doc comment. + public const uint PreviewRenderId = 0xDA11_D032u; + + /// + /// Retail's held-pose (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 + /// m_didAnimationRest assignment (pseudo-C ~0x004EE948, + /// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest + /// DID — the one divergence from the paperdoll, which never needs an + /// Olthoi branch because a live player can't be one. + /// + private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => 0x10000011u, + (uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u, + _ => 0x10000005u, + }; + + /// + /// 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 for its RESULT — a thin wrapper over + /// that returns exactly the same + /// WorldEntity (rest-posed) this method's existing callers already + /// expect; ALL 3 of those callers' tests still pass unmodified. Not + /// byte-identical internally any more — + /// also resolves the idle DID and loads the idle Animation before this + /// wrapper discards them, extra dat work the pre-CC6b method never did. + /// New code that wants retail's true default (idle loop playing) should call + /// and wrap the result in a + /// instead. + /// + /// + /// Shared exclusion object for every dat read this method performs. + /// DatCollection is NOT thread-safe (see + /// claude-memory/feedback_phase_a1_hotfix_saga.md) — every other + /// dat-touching renderer/resolver in this layer + /// (RetailPaperdollPoseApplicator, PlayerModeController, + /// DatProjectileSetupResolver, EquippedChildRenderController) + /// takes the SAME object datLock the composition root threads + /// through as RuntimeOptions/d.DatLock; callers MUST pass + /// that same shared instance, not a private lock, or this method's reads + /// race every other consumer's. + /// + public static WorldEntity? TryBuild( + IDatReaderWriter dats, + IAnimationLoader animations, + ChargenAppearanceResult appearance, + uint heritageId, + Quaternion heading, + 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); + + uint setupId = appearance.SetupId; + List drawableParts; + List restMeshRefs; + Animation? idleAnimation; + int idleLowFrame = 0, idleHighFrame = -1; + + // 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) + { + Setup? setup = dats.Get(setupId); + if (setup is null) + return null; + + var flattened = new List(SetupMesh.Flatten(setup)); + + foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges) + { + if (change.PartIndex < flattened.Count) + flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); + } + + // 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); + + drawableParts = new List(flattened.Count); + restMeshRefs = new List(flattened.Count); + for (int partIndex = 0; partIndex < flattened.Count; partIndex++) + { + MeshRef part = flattened[partIndex]; + if (dats.Get(part.GfxObjId) is null) + continue; // matches DatLiveEntityProjectionMaterializer's drawable filter. + + IReadOnlyDictionary? overrides = null; + if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) + overrides = perPart; + + 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 (drawableParts.Count == 0) + return null; + + // 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; + } + } + + var entity = new WorldEntity + { + Id = PreviewRenderId, + ServerGuid = PreviewServerGuid, + SourceGfxObjOrSetupId = setupId, + Position = Vector3.Zero, + Rotation = heading, + 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 + /// ObjDesc's subpalettes, safe to call outside datLock. + private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance) + { + if (appearance.ObjDesc.SubPalettes.Count == 0) + return null; + + var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count]; + for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++) + { + ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i]; + ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors); + } + return new PaletteOverride(appearance.BasePaletteId, ranges); + } + + /// No dat access — pure projection, safe to call outside + /// datLock. + private static PartOverride[] BuildPartOverrides(ChargenAppearanceResult appearance) + { + var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count]; + for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++) + { + ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i]; + partOverrides[i] = new PartOverride(change.PartIndex, change.PartId); + } + return partOverrides; + } + + /// + /// Overwrites every part's transform from the resolved 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 + /// alignment holds even if a later part turns out to have a missing + /// GfxObj. No-ops (keeps the default placement frame) when the pose + /// DID or its animation can't be resolved. + /// + private static void ApplyHeldPoseTransforms( + IDatReaderWriter dats, + IAnimationLoader animations, + Setup setup, + uint poseEnum, + List flattened) + { + uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum); + if ((poseDid >> 24) != 0x03u) + return; + + Animation? animation = animations.LoadAnimation(poseDid); + if (animation is null || animation.PartFrames.Count == 0) + return; + + var frame = animation.PartFrames[^1]; + for (int index = 0; index < flattened.Count; index++) + { + Vector3 scale = index < setup.DefaultScale.Count ? setup.DefaultScale[index] : Vector3.One; + Vector3 origin = Vector3.Zero; + Quaternion orientation = Quaternion.Identity; + if (index < frame.Frames.Count) + { + origin = frame.Frames[index].Origin; + orientation = frame.Frames[index].Orientation; + } + + flattened[index] = new MeshRef( + flattened[index].GfxObjId, + RetailHeldPose.ComposePartTransform(scale, origin, orientation)); + } + } + + /// + /// Part-index → (old texture id → new texture id) resolution, verbatim + /// port of DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides's + /// algorithm against instead of the + /// wire's CreateObject.TextureChange. + /// + private static Dictionary>? ResolveSurfaceOverrides( + IDatReaderWriter dats, + IReadOnlyList parts, + IReadOnlyList textureChanges) + { + if (textureChanges.Count == 0) + return null; + + var oldToNewByPart = new Dictionary>(); + foreach (ChargenTextureChange change in textureChanges) + { + if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew)) + { + oldToNew = []; + oldToNewByPart.Add(change.PartIndex, oldToNew); + } + oldToNew[change.OldTextureId] = change.NewTextureId; + } + + var result = new Dictionary>(); + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew)) + continue; + + GfxObj? gfx = dats.Get(parts[partIndex].GfxObjId); + if (gfx is null) + continue; + + Dictionary? resolved = null; + foreach (var surfaceQid in gfx.Surfaces) + { + uint surfaceId = (uint)surfaceQid; + Surface? surface = dats.Get(surfaceId); + if (surface is null) + continue; + uint originalTexture = (uint)surface.OrigTextureId; + if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture)) + continue; + + (resolved ??= [])[surfaceId] = newTexture; + } + + if (resolved is not null) + result[partIndex] = resolved; + } + + return result.Count == 0 ? null : result; + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs new file mode 100644 index 00000000..fa6a7490 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -0,0 +1,90 @@ +using AcDream.App.Rendering.Wb; +using AcDream.App.UI; +using AcDream.Core.Lighting; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Chargen-specific facade over the shared private creature viewport +/// () — CC6a's foundation half of +/// the campaign plan's "chargen preview renderer" deliverable. Mirrors +/// 's shape exactly, with a +/// heading-capable in place of the +/// paperdoll's fixed one. +/// +/// +/// NOT wired here (CC6b 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. +/// +/// +/// +/// 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 : + IUiViewportRenderer, + IDisposable +{ + private readonly PrivateEntityViewportRenderer _renderer; + private readonly ChargenPreviewViewportCamera _camera; + + internal ChargenPreviewRenderer( + IWorldPassScope scope, + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frames, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IEntityTextureLifetime textureLifetime, + IWbMeshAdapter meshAdapter, + uint heritageId = 0u) + { + _camera = new ChargenPreviewViewportCamera(heritageId); + _renderer = new PrivateEntityViewportRenderer( + scope, + device, + frames, + dispatcher, + lightUbo, + textureLifetime, + meshAdapter, + ChargenPreviewEntityBuilder.PreviewRenderId, + _camera, + "chargen preview"); + } + + public bool TextureIsBottomUp => _renderer.TextureIsBottomUp; + + /// + /// Re-derives the fixed per-heritage camera eye + /// () — call whenever + /// the selected heritage changes, BEFORE the next . + /// + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity); + + public uint Render(int width, int height) => _renderer.Render(width, height); + + public void Dispose() => _renderer.Dispose(); +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs new file mode 100644 index 00000000..323fdb19 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -0,0 +1,126 @@ +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 `%=`. Fix round F3: Binary + /// Ninja literally renders x87_r7_1 = x87_r6_3 at 0x0047CAEB + /// inside the counter-clockwise branch — reassigning the local that held + /// the "now" timestamp to the just-computed delta-degrees value — which + /// would make the 0x0047CB3D store into m_dLastRotateTime + /// write delta-degrees instead of the timestamp for CCW only; that is an + /// x87-FPU-stack modeling artifact of the decompiler, not real retail + /// behavior (a shipped feature where every counter-clockwise rotation + /// visibly diverges from clockwise is implausible, and + /// claude-memory/feedback_bn_decomp_field_names.md names exactly + /// this x87-stack-register mislabeling as a known decompiler artifact + /// class), so this port stores now into _lastRotateTime + /// unconditionally in BOTH directions. + /// + public void Tick(double now) + { + 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..1e018063 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs @@ -0,0 +1,162 @@ +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). +/// +/// +/// One owner of the zoom state (fix round F2): retail's +/// m_bZoomedIn is a SINGLE field on gmCGAppearancePage that +/// gates both the camera target AND the animation swap — there is no way +/// for retail's own camera and animation to disagree about which zoom state +/// they're in. The first cut of this port kept two independent bools (one +/// here, one on ) synced only by +/// / calling a NULLABLE animator +/// parameter — a null pass, or any direct +/// call bypassing this +/// controller, would desync the camera's target from the animation's pose. +/// This class now takes its as a +/// REQUIRED constructor dependency and reads +/// straight through to — the +/// animator is the sole state owner, matching retail's own single-field +/// design, and there is no longer a second bool that could disagree with it. +/// +/// +/// +/// Retail drives once per frame from a global-message-3 +/// tick while m_bShouldZoomAnimate is set +/// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the +/// 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 readonly ChargenPreviewAnimator _animator; + private Vector3 _startEye; + private Vector3 _targetEye; + private double _animStartTime; + private double _animDuration; + private bool _shouldAnimate; + + public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera, ChargenPreviewAnimator animator) + { + ArgumentNullException.ThrowIfNull(camera); + ArgumentNullException.ThrowIfNull(animator); + _heritageId = heritageId; + Camera = camera; + _animator = animator; + } + + public ChargenPreviewCamera Camera { get; } + + /// + /// Mirrors retail's m_bZoomedIn — a straight read-through to + /// (see this class's own + /// "one owner" doc above), which itself defaults false per + /// gmCGAppearancePage::InitializePage @ 0x0047FDD0's explicit + /// this->m_bZoomedIn = 0; at 0x004802C3 — written right + /// after that same function points the camera at the zoomed-IN + /// per-heritage eye (0x00480286-0x0048029E). One retail quirk + /// this produces: the character starts framed close-up while + /// NOT-zoomed-in, so the first Zoom In click (once mounted) tweens + /// close-eye→close-eye — visually null — while still freezing the + /// animation; this port reproduces it faithfully. + /// + public bool IsZoomedIn => _animator.IsZoomedIn; + + /// + /// gmCGAppearancePage::ZoomIn @ 0x0047CF00: no-op if already + /// zoomed in (retail's own early-return guard). Otherwise starts a tween + /// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage + /// profile and swaps the animator to the frozen rest pose IMMEDIATELY + /// (gmCG3DView::StopAnimation's call site, pseudo-C ~0x0047d024, + /// precedes the tween's own completion by definition — it runs once, + /// synchronously, inside ZoomIn itself). + /// + public void ZoomIn() + { + if (IsZoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId)); + _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 the animator back to the playing idle + /// loop immediately, mirroring . + /// + public void ZoomOut() + { + if (!IsZoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId)); + _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.Content/CharGen/ChargenAppearanceCatalog.cs b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs new file mode 100644 index 00000000..fe1c7579 --- /dev/null +++ b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs @@ -0,0 +1,105 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; +using AcDream.Core.CharGen; +using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable; +using DatPalSet = DatReaderWriter.DBObjs.PalSet; +using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect; +using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette; + +namespace AcDream.Content.CharGen; + +/// +/// DAT-backed / +/// implementation: reads PalSet (0x0F......) and ClothingTable (0x19......) +/// dat objects on demand and projects them into 's +/// pure Core types, matching ChargenTableReader's "no Chorizite leak" +/// discipline for everything it returns. Both lookups cache by dat id — a +/// live preview re-composes on every appearance change, and the same +/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and +/// re-selections within one session. +/// +public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource +{ + private readonly IDatReaderWriter _dats; + private readonly ConcurrentDictionary _palSets = new(); + private readonly ConcurrentDictionary _clothingTables = new(); + + public ChargenAppearanceCatalog(IDatReaderWriter dats) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + } + + public ChargenPalSet? TryGetPalSet(uint palSetId) => + _palSets.GetOrAdd(palSetId, LoadPalSet); + + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _clothingTables.GetOrAdd(clothingTableId, LoadClothingTable); + + private ChargenPalSet? LoadPalSet(uint id) + { + DatPalSet? palSet = _dats.Get(id); + if (palSet is null) + return null; + + var ids = new uint[palSet.Palettes.Count]; + for (int i = 0; i < palSet.Palettes.Count; i++) + ids[i] = palSet.Palettes[i].DataId; + return new ChargenPalSet(Array.AsReadOnly(ids)); + } + + private ChargenClothingTable? LoadClothingTable(uint id) + { + DatClothingTable? table = _dats.Get(id); + if (table is null) + return null; + + var baseEffects = new Dictionary( + table.ClothingBaseEffects.Count); + foreach (var pair in table.ClothingBaseEffects) + baseEffects[pair.Key.DataId] = ProjectBaseEffect(pair.Value.CloObjectEffects); + + var templates = new Dictionary( + table.ClothingSubPalEffects.Count); + foreach (var pair in table.ClothingSubPalEffects) + templates[pair.Key] = ProjectPaletteTemplate(pair.Value.CloSubPalettes); + + return new ChargenClothingTable( + baseEffects.ToFrozenDictionary(), + templates.ToFrozenDictionary()); + } + + private static ChargenClothingBaseEffect ProjectBaseEffect( + IReadOnlyList objectEffects) + { + var partChanges = new List(objectEffects.Count); + var textureChanges = new List(); + foreach (DatCloObjectEffect effect in objectEffects) + { + var partIndex = (byte)effect.Index; + partChanges.Add(new ChargenAnimPartChange(partIndex, effect.ModelId.DataId)); + foreach (var tex in effect.CloTextureEffects) + { + textureChanges.Add(new ChargenTextureChange( + partIndex, tex.OldTexture.DataId, tex.NewTexture.DataId)); + } + } + return new ChargenClothingBaseEffect( + Array.AsReadOnly(partChanges.ToArray()), + Array.AsReadOnly(textureChanges.ToArray())); + } + + private static ChargenClothingPaletteTemplate ProjectPaletteTemplate( + IReadOnlyList subPalettes) + { + var choices = new ChargenClothingSubPaletteChoice[subPalettes.Count]; + for (int i = 0; i < subPalettes.Count; i++) + { + DatCloSubPalette sub = subPalettes[i]; + var ranges = new ChargenClothingSubPaletteRange[sub.Ranges.Count]; + for (int j = 0; j < sub.Ranges.Count; j++) + ranges[j] = new ChargenClothingSubPaletteRange(sub.Ranges[j].Offset, sub.Ranges[j].NumColors); + choices[i] = new ChargenClothingSubPaletteChoice(sub.PaletteSet.DataId, Array.AsReadOnly(ranges)); + } + return new ChargenClothingPaletteTemplate(Array.AsReadOnly(choices)); + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs new file mode 100644 index 00000000..c7629130 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs @@ -0,0 +1,458 @@ +namespace AcDream.Core.CharGen; + +/// +/// The resolved render description +/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies +/// to it (CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0 is +/// retail's equivalent apply step). The three diagnostic lists let callers +/// (and CC6a's installed-DAT test) verify a selection resolved with no +/// missing dat data without needing to re-walk the composition themselves. +/// +/// +/// The body Setup dat id (0x02......) to build the preview mesh from — +/// gender.SetupId, overridden by the selected hair style's +/// AlternateSetup when it is neither 0 nor retail's INVALID_DID +/// (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 +/// palette a mesh builder should pass as the entity's base, NOT +/// ObjDesc.PaletteId (retail's own on-disk BaseObjDesc.PaletteId +/// field is unused for this purpose; cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:58, +/// which sets PropertyDataId.PaletteBase from sex.BasePalette +/// directly). +/// +/// +/// The composed subpalette/texture/part-swap deltas, in retail's exact +/// application order (see ). +/// +public sealed record ChargenAppearanceResult( + uint SetupId, + uint BasePaletteId, + ChargenObjDesc ObjDesc, + IReadOnlyList MissingPalSetIds, + IReadOnlyList MissingClothingTableIds, + IReadOnlyList ClothingTablesMissingBaseEffectForSetup); + +/// +/// Index→ObjDesc appearance factory: the missing piece the campaign plan's +/// "acdream seams" section names (Appearance building: DollEntityBuilder.Build +/// is index-agnostic but reads a LIVE entity; chargen needs a new index→dat +/// →ObjDesc factory). Pure — no Chorizite types on this type's public +/// surface, matching CC1's ChargenOptions family; PalSet/ClothingTable +/// dat reads are pushed behind / +/// , whose production implementation +/// (AcDream.Content.CharGen.ChargenAppearanceCatalog) does the actual +/// dat work. +/// +/// +/// Ports gmCG3DView::Update @ 0x004EE9D0's ObjDesc rebuild verbatim, +/// in its EXACT append order (verified against the decompiled control flow, +/// not inferred from the UI's tab order or the wire's field order, both of +/// which differ — see the per-slot XML doc below): +/// +/// +/// Base body (Sex_CG.BaseObjDesc). +/// Hair style overlay (HairStyle_CG.ObjDesc), if selected. +/// Clothing, in retail's own order — Headgear, Trousers, Shirt, +/// Footwear (NOT the UI tab order 5/6/7/8 = headgear/shirt/trousers/ +/// footwear, and NOT the wire field order from CC2's 0xF656 builder, +/// which is also headgear/shirt/trousers/footwear). Each slot applies +/// its ClothingBase part/texture overrides unconditionally, then +/// — only when a color is also selected — its dye subpalette via +/// ClothingTable::BuildObjDesc @ 0x005A7900. +/// Eyes strip overlay (bald variant when the selected hair style's +/// Bald flag is set), if selected. +/// Nose strip overlay, if selected. +/// Mouth strip overlay, if selected. +/// Skin subpalette — UNCONDITIONAL, no "if selected" guard in +/// retail (the decompiled block runs every time, unlike every style/ +/// color slot above and below it, which all gate on retail's +/// 0xFFFFFFFF sentinel). +/// Hair color subpalette, if selected. +/// Eye color subpalette, if selected. +/// +/// +public static class ChargenAppearanceFactory +{ + /// + /// Retail's HUMAN_SETUP_ID fallback (ACViewer.Entity.Enum.SetupConst.HumanMale + /// = 0x02000001; the same constant gmCG3DView's ctor and + /// ::Update fall back to when no valid body Setup is resolvable). + /// + public const uint HumanSetupId = 0x02000001u; + + /// + /// Retail's IDClass "unset" sentinel (INVALID_DID, + /// 0xFFFFFFFF — acclient.h:39909). CharGenState::GetSetupID @ + /// 0x005C5B22 and gmCG3DView::Update's own checks + /// (~0x004EEA51/0x004EEA5F) both test a Setup id against THIS value, not + /// zero — a hair style whose AlternateSetup field happens to + /// store this sentinel must be treated as "no override," exactly like + /// zero, or the factory would hand a bogus Setup id to + /// Get<Setup> and produce no preview at all. + /// + private const uint InvalidDid = 0xFFFFFFFFu; + + /// + /// Skin subpalette overlay range, retail's hard-coded literal at + /// gmCG3DView::Update ~0x004EF066-0x004EF07E: real byte offset 0, + /// real color count 192 (0xC0), packed to 's + /// *8 on-disk units as (0, 24). + /// + private const byte SkinRangeOffset = 0; + private const byte SkinRangeNumColors = 24; // 192 / 8 + + /// + /// Hair color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF0FA-0x004EF116: real offset 192 (0xC0), real count 64 (0x40), + /// packed to (24, 8). + /// + private const byte HairRangeOffset = 24; // 192 / 8 + private const byte HairRangeNumColors = 8; // 64 / 8 + + /// + /// Eye color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF15A-0x004EF16E: real offset 256 (0x100), real count 64 + /// (0x40), packed to (32, 8). + /// + private const byte EyeRangeOffset = 32; // 256 / 8 + private const byte EyeRangeNumColors = 8; // 64 / 8 + + /// + /// Composes a preview appearance description for one heritage/gender + + /// selection, or returns false when the heritage/gender itself doesn't + /// resolve (mirrors the Try* convention + /// already uses). Never throws on missing PalSet/ClothingTable data — + /// a miss is recorded in the result's diagnostic lists and that single + /// contribution is skipped, matching retail's own "hash miss → no-op, + /// caller never checks BuildObjDesc's return value" behavior. + /// + /// + /// 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, + int genderKey, + ChargenAppearanceSelection selection, + IChargenPalSetSource palSets, + IChargenClothingTableSource clothingTables, + out ChargenAppearanceResult result, + uint alternateSetupIdOverride = InvalidDid) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(palSets); + ArgumentNullException.ThrowIfNull(clothingTables); + + result = default!; + if (!options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + || !heritage.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender)) + { + return false; + } + + var missingPalSets = new List(); + var missingClothingTables = new List(); + var absentBaseEffects = new List(); + + // ── 1. body Setup id ──────────────────────────────────────────── + uint setupId = gender.SetupId; + ChargenHairStyle? hairStyle = null; + if (selection.HairStyle != ChargenAppearanceSelection.Unset + && selection.HairStyle < (uint)gender.HairStyles.Count) + { + hairStyle = gender.HairStyles[(int)selection.HairStyle]; + if (hairStyle.AlternateSetup != 0 && 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; + + // ── 2. ObjDesc accumulation, retail's exact append order ─────── + var subPalettes = new List(); + var textureChanges = new List(); + var animPartChanges = new List(); + + Append(gender.BaseObjDesc, subPalettes, textureChanges, animPartChanges); + if (hairStyle is not null) + Append(hairStyle.ObjDesc, subPalettes, textureChanges, animPartChanges); + + ComposeClothingSlot( + gender.Headgears, selection.HeadgearStyle, + gender.ClothingColors, selection.HeadgearColor, selection.HeadgearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Pants, selection.TrousersStyle, + gender.ClothingColors, selection.TrousersColor, selection.TrousersShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Shirts, selection.ShirtStyle, + gender.ClothingColors, selection.ShirtColor, selection.ShirtShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Footwear, selection.FootwearStyle, + gender.ClothingColors, selection.FootwearColor, selection.FootwearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + + if (selection.EyesStrip != ChargenAppearanceSelection.Unset + && selection.EyesStrip < (uint)gender.EyeStrips.Count) + { + ChargenEyeStrip strip = gender.EyeStrips[(int)selection.EyesStrip]; + bool bald = hairStyle?.Bald == true; + Append(bald ? strip.BaldObjDesc : strip.ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.NoseStrip != ChargenAppearanceSelection.Unset + && selection.NoseStrip < (uint)gender.NoseStrips.Count) + { + Append(gender.NoseStrips[(int)selection.NoseStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.MouthStrip != ChargenAppearanceSelection.Unset + && selection.MouthStrip < (uint)gender.MouthStrips.Count) + { + Append(gender.MouthStrips[(int)selection.MouthStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + + // ── Skin subpalette: UNCONDITIONAL (no selection gate in retail) ─ + ChargenPalSet? skinPalSet = palSets.TryGetPalSet(gender.SkinPalSetId); + if (skinPalSet is null) + { + missingPalSets.Add(gender.SkinPalSetId); + } + else + { + int skinIndex = ChargenPalSetMath.GetPaletteIndex(skinPalSet.PaletteIds.Count, selection.SkinShade); + if (skinIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + skinPalSet.PaletteIds[skinIndex], SkinRangeOffset, SkinRangeNumColors)); + } + } + + if (selection.HairColor != ChargenAppearanceSelection.Unset + && selection.HairColor < (uint)gender.HairColors.Count) + { + uint hairPalSetId = gender.HairColors[(int)selection.HairColor]; + ChargenPalSet? hairPalSet = palSets.TryGetPalSet(hairPalSetId); + if (hairPalSet is null) + { + missingPalSets.Add(hairPalSetId); + } + else + { + int hairIndex = ChargenPalSetMath.GetPaletteIndex(hairPalSet.PaletteIds.Count, selection.HairShade); + if (hairIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + hairPalSet.PaletteIds[hairIndex], HairRangeOffset, HairRangeNumColors)); + } + } + } + + if (selection.EyeColor != ChargenAppearanceSelection.Unset + && selection.EyeColor < (uint)gender.EyeColors.Count) + { + // Direct Palette id — no PalSet/shade indirection (see ChargenPalSet's doc). + uint eyePaletteId = gender.EyeColors[(int)selection.EyeColor]; + subPalettes.Add(new ChargenSubPalette(eyePaletteId, EyeRangeOffset, EyeRangeNumColors)); + } + + var objDesc = new ChargenObjDesc( + gender.BasePaletteId, + subPalettes.AsReadOnly(), + textureChanges.AsReadOnly(), + animPartChanges.AsReadOnly()); + + result = new ChargenAppearanceResult( + setupId, + gender.BasePaletteId, + objDesc, + missingPalSets.AsReadOnly(), + missingClothingTables.AsReadOnly(), + absentBaseEffects.AsReadOnly()); + return true; + } + + private static void Append( + ChargenObjDesc source, + List subPalettes, + List textureChanges, + List animPartChanges) + { + subPalettes.AddRange(source.SubPalettes); + textureChanges.AddRange(source.TextureChanges); + animPartChanges.AddRange(source.AnimPartChanges); + } + + private static void ComposeClothingSlot( + IReadOnlyList gearOptions, + uint styleIndex, + IReadOnlyList clothingColors, + uint colorIndex, + double shade, + uint bodySetupId, + IChargenClothingTableSource clothingTables, + IChargenPalSetSource palSets, + List subPalettes, + List textureChanges, + List animPartChanges, + List missingClothingTables, + List missingPalSets, + List absentBaseEffects) + { + if (styleIndex == ChargenAppearanceSelection.Unset || styleIndex >= (uint)gearOptions.Count) + return; + + ChargenGearOption gear = gearOptions[(int)styleIndex]; + ChargenClothingTable? table = clothingTables.TryGetClothingTable(gear.ClothingTableId); + if (table is null) + { + missingClothingTables.Add(gear.ClothingTableId); + return; + } + + if (table.BaseEffectsBySetupId.TryGetValue(bodySetupId, out ChargenClothingBaseEffect? baseEffect)) + { + animPartChanges.AddRange(baseEffect.PartChanges); + textureChanges.AddRange(baseEffect.TextureChanges); + } + else + { + absentBaseEffects.Add(gear.ClothingTableId); + } + + if (colorIndex == ChargenAppearanceSelection.Unset || colorIndex >= (uint)clothingColors.Count) + return; + + uint paletteTemplateId = clothingColors[(int)colorIndex]; + if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template)) + return; // retail: hash miss on the OUTER palette-template lookup is a silent no-op. + + foreach (ChargenClothingSubPaletteChoice choice in template.Choices) + { + ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId); + if (palSet is null) + { + // Retail's own inner loop (ClothingTable::BuildObjDesc + // ~0x005A7B24-0x005A7BD3) returns 0 IMMEDIATELY when + // DBObj::Get fails for one subpalEffect entry's PalSet + // (~0x005A7B32) — aborting every REMAINING choice in this + // same garment's palette template, not merely skipping the + // failed one. `break`, not `continue`, matches that; the + // miss is still recorded so callers can see it happened. + missingPalSets.Add(choice.PalSetId); + break; + } + + int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade); + if (index < 0) + continue; + + uint paletteId = palSet.PaletteIds[index]; + foreach (ChargenClothingSubPaletteRange range in choice.Ranges) + { + subPalettes.Add(new ChargenSubPalette( + paletteId, + PackOffset(range.Offset), + PackNumColors(range.NumColors))); + } + } + } + + /// + /// Converts a real (unpacked) clothing subpalette offset into + /// 's packed *8 on-disk unit. Throws + /// rather than silently truncating on a shape we've never seen and + /// don't know how to represent losslessly (guards against the + /// unchecked-narrowing footgun a plain (byte)(value / 8) cast + /// would otherwise hide). + /// + private static byte PackOffset(uint realOffset) + { + if (realOffset % 8u != 0 || realOffset > 2040u) + { + throw new ArgumentOutOfRangeException( + nameof(realOffset), + realOffset, + "Clothing subpalette range offset does not fit the packed *8 byte " + + "convention (expected a multiple of 8 in [0, 2040])."); + } + return (byte)(realOffset / 8u); + } + + /// + /// Same packing as , plus retail's own explicit + /// "whole palette" sentinel: a packed NumColors of 0 means "the + /// entire palette" ('s + /// doc: "Length=0 is a sentinel meaning entire palette... defaulting to + /// 256*8"). A real count of exactly 2048 (256*8) IS that same value + /// spelled out in real units, so it packs to 0 BY DESIGN — not because + /// an unchecked (byte) cast happens to wrap 256 back to 0. + /// + private static byte PackNumColors(uint realNumColors) + { + if (realNumColors == 2048u) + return 0; + if (realNumColors % 8u != 0 || realNumColors > 2040u) + { + throw new ArgumentOutOfRangeException( + nameof(realNumColors), + realNumColors, + "Clothing subpalette range color count does not fit the packed *8 byte " + + "convention (expected a multiple of 8 in [0, 2040], or exactly 2048 " + + "for the whole-palette sentinel)."); + } + return (byte)(realNumColors / 8u); + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs new file mode 100644 index 00000000..f26b6427 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs @@ -0,0 +1,52 @@ +namespace AcDream.Core.CharGen; + +/// +/// The fourteen style/color indices plus the six f64 shades +/// needs to build a preview +/// description — field-for-field the same shape as CC3's +/// AcDream.Runtime.Session.RuntimeCharacterCreationAppearance (and, +/// through it, CharacterCreate.Appearance's wire fields), kept as a +/// SEPARATE type here rather than referenced directly because +/// AcDream.Runtime depends on AcDream.Core and not the other +/// way around. CC6b's job is the trivial field-by-field copy from the +/// Runtime owner's snapshot into this type. / +/// mirror retail's own sentinels exactly (same +/// citations CC3 already recorded): 0xFFFFFFFF for "nothing selected" +/// and the IEEE-754 -1.0 construction-time shade default +/// (CharGenState::Reset @ 0x005C68A0). +/// +public readonly record struct ChargenAppearanceSelection( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairStyle, + uint HairColor, + uint EyeColor, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade) +{ + public const uint Unset = 0xFFFFFFFFu; + public const double UnsetShade = -1.0; + + public static ChargenAppearanceSelection Default { get; } = new( + Unset, Unset, Unset, + Unset, Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + UnsetShade, UnsetShade, UnsetShade, + UnsetShade, UnsetShade, UnsetShade); +} diff --git a/src/AcDream.Core/CharGen/ChargenClothingTable.cs b/src/AcDream.Core/CharGen/ChargenClothingTable.cs new file mode 100644 index 00000000..3601bf48 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenClothingTable.cs @@ -0,0 +1,147 @@ +using System.Collections.Frozen; + +namespace AcDream.Core.CharGen; + +/// +/// One un-resolved dye-shade choice inside a clothing "palette template" +/// (retail's inner CloSubpalEffect array entry, one per +/// ClothingTable::BuildObjDesc @ 0x005A7900 loop iteration; Chorizite +/// projects the identical shape as DatReaderWriter.Types.CloSubPalette +/// — a PaletteSet id plus a list of overlay ranges). Offsets/counts +/// here are the REAL (unpacked) color units read straight off the dat +/// (installed-DAT probe: Aluvian male "Cloth Cap" headgear reads +/// off=2000,n=48 for every one of its 28 palette-template entries) — the +/// *8-packed byte convention only applies to the OUTPUT +/// , converted once at composition time +/// (). +/// +public readonly record struct ChargenClothingSubPaletteRange(uint Offset, uint NumColors); + +/// +/// One resolvable-by-shade colour choice for a clothing palette template: +/// the PalSet id (0x0F......) to resolve via +/// , plus every overlay range +/// to apply once resolved. +/// +public readonly record struct ChargenClothingSubPaletteChoice( + uint PalSetId, + IReadOnlyList Ranges); + +/// +/// One clothing-table "palette template" (retail's CloPaletteTemplate, +/// looked up in ClothingTable::_paletteTemplatesHash by the id +/// CharGenState::GetHeadgearPaletteTemplateID (and its Shirt/Trousers/ +/// Footwear siblings, all at 0x005C38F0-0x005C3980) return — which is itself +/// just a bounds-checked passthrough of Sex_CG.ClothingColors[index]: +/// every one of the four per-slot template-id arrays +/// (headgearPaletteTemplateIDs/shirtPaletteTemplateIDs/ +/// trousersPaletteTemplateIDs/footwearPaletteTemplateIDs) is +/// populated from the SAME single Sex_CG::ClothingColors dat field — +/// there is no per-clothing-slot color list in the dat schema at all. This +/// CONFIRMS (does not merely approximate) register row AP-208's shared-list +/// design in RuntimeCharacterCreationAppearance/ +/// ChargenAppearanceSlot — installed-DAT probe: Aluvian male's +/// ClothingColors = {9,6,4,8,7,5,2,3,13}, and the "Cloth Cap" +/// headgear's ClothingSubPalEffects keys include 2,3,4,5,6,7,8,9,13 — +/// the shared list's raw values ARE the template-id keys, verified live. +/// +public sealed record ChargenClothingPaletteTemplate( + IReadOnlyList Choices) +{ + public static ChargenClothingPaletteTemplate Empty { get; } = + new(Array.Empty()); +} + +/// +/// One body-Setup-specific part/texture override set (retail's +/// ClothingBaseEffect, applied by +/// ClothingBase::ApplyPartAndTextureChanges @ 0x005A8EB0): for each +/// CloObjectEffect, an unconditional +/// (part index → replacement GfxObj) plus every +/// the SAME object effect carries for +/// that part. +/// +public sealed record ChargenClothingBaseEffect( + IReadOnlyList PartChanges, + IReadOnlyList TextureChanges) +{ + public static ChargenClothingBaseEffect Empty { get; } = new( + Array.Empty(), + Array.Empty()); +} + +/// +/// Pure projection of one ClothingTable dat object (0x19......, retail +/// ClothingTable::Unpack / Chorizite +/// DatReaderWriter.DBObjs.ClothingTable). One instance is referenced +/// per — a single garment +/// CHOICE (e.g. "Cloth Cowl") carries its own table covering every body +/// Setup it can be worn on plus every dye choice offered for it. +/// +/// +/// Deliberate scope cut (CC6a) — MEASURED, not just asserted: retail's +/// ClothingTable::BuildObjDesc falls back through a chain of ~8 +/// hard-coded Setup-id substitutions (Umbraen crown/no-crown/void, +/// Penumbraen, Undead skeleton/zombie, Anakshay) when +/// has no direct entry for the requested +/// body Setup. CC6a's composer looks up +/// directly and skips a slot's part/texture contribution on a miss (this is +/// the OUTER lookup — ClothingTable::_cloBaseHash — whose retail +/// miss behavior is genuinely a no-op the caller never checks; the SEPARATE +/// inner per-choice PalSet lookup inside the same function's subpalette loop +/// has its own, stricter, abort-on-miss behavior — see +/// ChargenAppearanceFactory.ComposeClothingSlot's own doc, ported +/// faithfully there) rather than porting the Setup-substitution chain. The +/// installed-DAT catalog test (ChargenAppearanceCatalogInstalledDatTests) +/// MEASURED this directly across all 26 heritage/gender combinations rather +/// than assuming it: for the 9 standard heritages where retail's own UI +/// actually shows clothing controls (everything except Gear Knight and the +/// two Olthoi variants, which retail hides the clothes button for entirely +/// — gmCGAppearancePage::Update @ 0x0047E8F0's +/// m_pClothesButton->SetVisible(0) branches for +/// mHeritageGroup == 6 and == 0xc || == 0xd), the default +/// gear choices resolve against their own body Setup with ZERO missing +/// coverage. Undead IS a real gap — retail DOES show clothing +/// controls for Undead, and MEASURED coverage is missing for ALL FOUR +/// clothing slots (headgear, trousers, shirt, AND footwear — not just three +/// of the four), on both genders: neither gender's live body Setup has a +/// entry in any of its four default gear +/// choices' clothing tables, because Undead's live body Setup IS one of the +/// skeleton/zombie variants the un-ported substitution chain exists to +/// redirect. A live preview for Undead will therefore render its default +/// clothing selection with NO part/texture override applied on any of the +/// four slots (the underlying body shows through unclothed) until the +/// substitution chain — or an equivalent per-heritage default-clothing-setup +/// mapping — lands. Filed as a known CC6a limitation for CC6b/a follow-up +/// rather than silently "confirmed unreachable." +/// +/// +public sealed record ChargenClothingTable( + IReadOnlyDictionary BaseEffectsBySetupId, + IReadOnlyDictionary PaletteTemplatesById) +{ + public static ChargenClothingTable Empty { get; } = new( + FrozenDictionary.Empty, + FrozenDictionary.Empty); +} + +/// +/// Resolves a PalSet dat id (0x0F......) to its pure projection. The +/// production implementation (AcDream.Content.CharGen.ChargenAppearanceCatalog) +/// reads and caches the real dat object; this interface keeps +/// free of any Chorizite dependency +/// (unit tests supply a hand-built fake). +/// +public interface IChargenPalSetSource +{ + ChargenPalSet? TryGetPalSet(uint palSetId); +} + +/// +/// Resolves a ClothingTable dat id (0x19......) to its pure projection. +/// Same production/test split as . +/// +public interface IChargenClothingTableSource +{ + ChargenClothingTable? TryGetClothingTable(uint clothingTableId); +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSet.cs b/src/AcDream.Core/CharGen/ChargenPalSet.cs new file mode 100644 index 00000000..e822d92c --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSet.cs @@ -0,0 +1,23 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure projection of a PalSet dat object (0x0F......, retail +/// PalSet::Unpack / Chorizite DatReaderWriter.DBObjs.PalSet): +/// the ordered list of Palette dat ids (0x04......) a shade fraction picks +/// from via . Every appearance +/// color slot that resolves "by shade" — skin (ChargenGenderOptions.SkinPalSetId), +/// hair (ChargenGenderOptions.HairColors[i]), and every clothing +/// dye choice (ChargenClothingSubPaletteChoice.PalSetId) — reads one +/// of these. Eye color is the one exception: retail uses the raw entry +/// from ChargenGenderOptions.EyeColors directly as a Palette id, no +/// PalSet/shade indirection (gmCG3DView::Update pseudo-C ~0x004EF12F; +/// cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:100, +/// which sets EyesPalette straight from sex.EyeColorList[eyeColor] +/// with no GetPaletteID call, unlike the Skin/Hair lines immediately +/// above it). +/// +public sealed record ChargenPalSet(IReadOnlyList PaletteIds) +{ + public static ChargenPalSet Empty { get; } = new(Array.Empty()); +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSetMath.cs b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs new file mode 100644 index 00000000..2076013b --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs @@ -0,0 +1,57 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's shade→palette-index resolution +/// (PalSet::GetPaletteID @ 0x005AC570, invoked from +/// gmCG3DView::Update @ 0x004EE9D0 for the skin/hair subpalette +/// build and from ClothingTable::BuildObjDesc @ 0x005A7900 for every +/// clothing-slot dye choice). The decompiled body is genuinely FPU-elided — +/// the _ftol2() truncating-cast operand is lost to the decompiler, +/// and can only be read as "some product of -ish and +/// -ish operands" from the surrounding x87 stack +/// traffic — but the decomp's own control-flow SHAPE is still verifiable +/// independent of that lost operand: a two-sided FPU compare at +/// 0x005AC5A0 gating on >= 0.0, consistent with a +/// [0,1] shade bounds check before the cast. What resolves the +/// elided operand is ACE's ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID, +/// which carries the explicit comment "Taken from acclient.c +/// (PalSet::GetPaletteID)" against the exact formula below. That is TWO +/// sources (decomp control flow + ACE's cited port), not three: the +/// PaletteSet.cs file present in the vendored ACViewer checkout is +/// ACE's own file, not an independent reimplementation, and ACViewer's +/// ClothingTableList.xaml.cs:97 UI slider computes a DIFFERENT +/// expression for a DIFFERENT problem (mapping a shade back to a slider tick +/// position against Shades.Maximum, i.e. count-1, not +/// count) — neither corroborates this formula and both are dropped +/// from the evidence chain here. +/// +public static class ChargenPalSetMath +{ + /// + /// Resolves a shade fraction to an index into a palette-id list of the + /// given . Returns -1 (retail's + /// INVALID_DID outcome) when is + /// non-positive or falls outside + /// [0.0, 1.0] — including retail's own -1.0 "unset" + /// sentinel (CharGenState::Reset @ 0x005C68A0), which is + /// deliberately out of range so an untouched shade resolves to + /// "nothing," matching retail. Callers should treat -1 as "skip this + /// subpalette contribution" rather than emit a placeholder id. + /// + public static int GetPaletteIndex(int count, double shade) + { + if (count <= 0 || shade < 0.0 || shade > 1.0) + return -1; + + // Truncating cast, exactly as ACE's cited port and the decomp's + // _ftol2() (which truncates toward zero on x86, matching a plain + // C-style (int) cast here since count > 0 and 0 <= shade <= 1 keep + // the product non-negative). + int index = (int)((count - 0.000001) * shade); + if (index < 0) + index = 0; + if (index > count - 1) + index = count - 1; + return index; + } +} diff --git a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs new file mode 100644 index 00000000..788dfe8f --- /dev/null +++ b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs @@ -0,0 +1,124 @@ +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). Tracked as +/// docs/ISSUES.md #403 so the follow-up has an owner. +/// +/// +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/ChargenPreviewCameraTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs new file mode 100644 index 00000000..1b0280f3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Pins 's retail-verbatim per-heritage +/// eye positions (gmCGAppearancePage::Update @ 0x0047E8F0, +/// cross-checked against the identical literals in ZoomIn/ZoomOut +/// @ 0x0047CF00/0x0047D050) and the zero-yaw/zero-pitch look +/// convention DollCameraTests already established for the shared private +/// viewport. +/// +public class ChargenPreviewCameraTests +{ + [Theory] + [InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Gharundim, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Gearknight, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Undead, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -0.850000024f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -1.85000002f, 1.85000002f)] + [InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -3.04999995f, 2.75f)] + public void ResolveDefaultEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z) + { + Vector3 eye = ChargenPreviewCamera.ResolveDefaultEye(heritageId); + Assert.Equal(x, eye.X, 4); + Assert.Equal(y, eye.Y, 4); + Assert.Equal(z, eye.Z, 4); + } + + [Theory] + [InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -2.5f, 0.95f)] + [InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -2.5f, 0.95f)] // ZoomOut has NO Tumerok special case, unlike the zoomed-in default. + [InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -3.79999995f, 1.14999998f)] + [InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -5.69999981f, 1.64999998f)] + public void ResolveZoomedOutEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z) + { + Vector3 eye = ChargenPreviewCamera.ResolveZoomedOutEye(heritageId); + Assert.Equal(x, eye.X, 4); + Assert.Equal(y, eye.Y, 4); + Assert.Equal(z, eye.Z, 4); + } + + [Fact] + public void Constructor_DefaultsToStandardHeritageEye_ForUnknownHeritageId() + { + var cam = new ChargenPreviewCamera(heritageId: 0u); + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(0u), cam.Eye); + } + + [Fact] + public void SetHeritage_UpdatesEyeToTheNewHeritagesProfile() + { + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + cam.SetHeritage((uint)ChargenHeritageGroup.Olthoi); + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye((uint)ChargenHeritageGroup.Olthoi), cam.Eye); + } + + [Fact] + public void View_LooksStraightDownPlusY_ZeroYawZeroPitch() + { + // Same identity-direction convention DollCameraTests pins for the paperdoll: + // retail SetCameraDirection(0,0,0) resets the view frame to IDENTITY. + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian) { Aspect = 1f }; + var forward = -new Vector3(cam.View.M13, cam.View.M23, cam.View.M33); + Assert.Equal(0f, forward.X, 4); + Assert.Equal(1f, forward.Y, 4); + Assert.Equal(0f, forward.Z, 4); + } + + [Fact] + public void Eye_RoundTripsThroughViewMatrixInversion() + { + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Olthoi) { Aspect = 1f }; + Assert.True(Matrix4x4.Invert(cam.View, out var inv)); + Vector3 eye = inv.Translation; + Assert.Equal(cam.Eye.X, eye.X, 3); + Assert.Equal(cam.Eye.Y, eye.Y, 3); + Assert.Equal(cam.Eye.Z, eye.Z, 3); + } + + [Fact] + public void Projection_IsFiniteAndUsesAspect() + { + var cam = new ChargenPreviewCamera { Aspect = 1.5f }; + Assert.True(float.IsFinite(cam.Projection.M11)); + Assert.NotEqual(0f, cam.Projection.M34); + } + + [Fact] + public void RotationSecondsPerRevolution_IsExactlyThreeSeconds() + { + // Raw double bits low32=0x00000000, high32=0x40080000 — no + // reconstruction needed, the decompiler shows this one cleanly. + Assert.Equal(3.0f, ChargenPreviewCamera.RotationSecondsPerRevolution); + } + + [Fact] + public void ZoomTweenDurationSeconds_IsExactlyZeroPointSix() + { + // Recovered by reinterpreting the decompiler's garbled float literal + // as the raw low-32-bit store and pairing it with the (clean) high + // dword; cross-confirmed via the -0.1 sentinel in ZoomIn/ZoomOut + // reconstructing to the well-known IEEE-754 bit pattern for -0.1. + Assert.Equal(0.6f, ChargenPreviewCamera.ZoomTweenDurationSeconds); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs new file mode 100644 index 00000000..4e59b9a5 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -0,0 +1,278 @@ +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Content; +using AcDream.Content.CharGen; +using AcDream.Content.Vfx; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Installed-DAT gate for — +/// mirrors 's env-gated skip pattern +/// (no unit-testable pure surface exists here the way +/// has one, because THIS builder's whole job +/// is resolving Setup/GfxObj/Surface/Animation dat data that +/// receives pre-resolved). +/// +public sealed class ChargenPreviewEntityBuilderTests +{ + private readonly ITestOutputHelper _out; + public ChargenPreviewEntityBuilderTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian. + Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)); + + var catalog = new ChargenAppearanceCatalog(adapter); + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + Assert.Empty(appearance.MissingPalSetIds); + Assert.Empty(appearance.MissingClothingTableIds); + + var animations = new RetailAnimationLoader(adapter); + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, appearance, heritageId: 1u, Quaternion.Identity, new object()); + + Assert.NotNull(entity); + Assert.NotEmpty(entity!.MeshRefs); + Assert.Equal(appearance.SetupId, entity.SourceGfxObjOrSetupId); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewServerGuid, entity.ServerGuid); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewRenderId, entity.Id); + Assert.NotNull(entity.PaletteOverride); + Assert.Equal(appearance.BasePaletteId, entity.PaletteOverride!.BasePaletteId); + + _out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}"); + } + + [Fact] + public void TryBuild_UnknownSetupId_ReturnsNull() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var animations = new RetailAnimationLoader(adapter); + + var bogusAppearance = new ChargenAppearanceResult( + SetupId: 0x0200_FFFFu, // Not a real installed Setup id. + BasePaletteId: 0u, + ObjDesc: ChargenObjDesc.Empty, + MissingPalSetIds: [], + MissingClothingTableIds: [], + ClothingTablesMissingBaseEffectForSetup: []); + + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity, new object()); + + Assert.Null(entity); + } + + [Fact] + public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi)); // Olthoi. + Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male) + || olthoi.GendersByKey.TryGetValue(2, out male)); + Assert.NotNull(male); + int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key; + + var catalog = new ChargenAppearanceCatalog(adapter); + var animations = new RetailAnimationLoader(adapter); + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 }, + catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, appearance, heritageId: 12u, Quaternion.Identity, new object()); + + // Just proves the Olthoi branch doesn't throw / silently fall through to + // "no mesh" — the exact pose DID differs internally (0x10000011 vs + // 0x10000005) but both should still resolve a drawable mesh from Olthoi's + // own Setup. + Assert.NotNull(entity); + Assert.NotEmpty(entity!.MeshRefs); + } + + /// + /// 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..9cd6158e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs @@ -0,0 +1,135 @@ +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); + } + + /// + /// F7: exercises the >360 -> -360 clamp arm (pseudo-C + /// ~0x0047cb1e-0x0047cb31), the one with readable decomp polarity — + /// unlike the CCW-branch FPU-stack artifact F3 documents, this branch's + /// test/subtract shape is unambiguous. One large clockwise tick pushes + /// heading past 360 in a single call. + /// + [Fact] + public void Tick_ClockwiseAdvancePast360_ClampsBackBySubtracting360() + { + var controller = new ChargenPreviewRotationController(); + controller.Toggle(ChargenRotateDirection.Clockwise); + controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. + controller.Tick(10.0 + 3.5); // 3.5s at 3s/rev = 420 deg -> 420, clamped to 60. + + Assert.Equal(60f, controller.HeadingDegrees, 3); + } + + [Fact] + public void ToOrientation_AtZeroHeading_IsIdentity() + { + 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..6172a140 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewZoomControllerTests.cs @@ -0,0 +1,194 @@ +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. Fix round F2: the controller now takes its +/// as a required constructor dependency +/// and owns no independent zoom-state bool of its own — every test here +/// builds a real (hand-fixture) animator rather than exercising a +/// camera-only path that no longer exists. +/// +public sealed class ChargenPreviewZoomControllerTests +{ + 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 Constructor_NullAnimator_Throws() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + Assert.Throws( + () => new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator: null!)); + } + + [Fact] + public void IsZoomedIn_ReadsThroughToTheAnimator_NoIndependentState() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + var animator = MakeAnimator(); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator); + + Assert.False(controller.IsZoomedIn); + + // Flip the animator's OWN state directly (bypassing the controller + // entirely) — since the controller now reads straight through, there + // is nothing to desync. + animator.SetZoomedIn(true); + Assert.True(controller.IsZoomedIn); + } + + [Fact] + public void ZoomIn_StartsATweenTowardTheDefaultEye_AndMarksZoomedIn() + { + var camera = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, MakeAnimator()); + + controller.ZoomIn(); + + 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, MakeAnimator()); + controller.ZoomIn(); // real tween: zoomed-out eye -> default eye. + controller.Tick(10.0); + controller.Tick(10.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds + 1.0); // fully complete it. + Vector3 eyeAfterCompletion = camera.Eye; + + controller.ZoomIn(); // 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, MakeAnimator()); + + controller.ZoomOut(); + + 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, MakeAnimator()); + controller.ZoomIn(); // reach the "zoomed in" state (zero-distance tween — Eye already there). + Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + controller.ZoomOut(); // NOW arms a real tween: default eye -> zoomed-out eye. + + controller.Tick(100.0); // seeds the tween's own start time (first tick of a fresh -0.1 sentinel). + controller.Tick(100.0 + ChargenPreviewCamera.ZoomTweenDurationSeconds / 2.0); + + 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, MakeAnimator()); + controller.ZoomIn(); // reach "zoomed in" (zero-distance). + Vector3 targetEye = ChargenPreviewCamera.ResolveZoomedOutEye((uint)ChargenHeritageGroup.Aluvian); + controller.ZoomOut(); // 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 animator = MakeAnimator(); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator); + + controller.ZoomIn(); + + // 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 animator = MakeAnimator(); + var controller = new ChargenPreviewZoomController((uint)ChargenHeritageGroup.Aluvian, camera, animator); + controller.ZoomIn(); + + controller.ZoomOut(); + + Assert.False(animator.IsZoomedIn); + Assert.Equal(new Vector3(1f, 0f, 0f), animator.Entity.MeshRefs[0].PartTransform.Translation); + } +} diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs new file mode 100644 index 00000000..98c0d0a4 --- /dev/null +++ b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs @@ -0,0 +1,299 @@ +using AcDream.Content.CharGen; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit.Abstractions; + +namespace AcDream.Content.Tests.CharGen; + +/// +/// Installed-DAT gate for + +/// together: for every one of the 13 +/// installed heritages' genders, composes a "pick the first offered option +/// everywhere, mid shade" selection and asserts it resolves with no missing +/// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance +/// bar ("every heritage/gender's default selection resolves to a complete +/// description with no missing dat ids"). ALSO pins the TS-82 measurement +/// with real assertions (not WriteLine-only diagnostics, per the CC6a +/// review fix round F7): the nine standard heritages with clothing UI shown +/// resolve zero ClothingBaseEffects gaps, and Undead resolves +/// EXACTLY the four measured gaps on both genders — see the class doc on +/// 's deliberate scope cut. +/// +/// Env-gated skip (house pattern, matched from +/// ChargenTableReaderInstalledDatTests/ContentConformanceDats): +/// returns green with a console SKIP note when no installed dat directory is +/// configured, rather than a true xUnit Skipped status — no other Content +/// installed-DAT test in this project uses Assert.Skip, so this stays +/// consistent with the rest of the suite rather than introducing a new +/// convention. +/// +public sealed class ChargenAppearanceCatalogInstalledDatTests +{ + private readonly ITestOutputHelper _out; + public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output; + + // ACE ACE.Entity.Enum.HeritageGroup ids. Gearknight (6)/Olthoi (12)/ + // OlthoiAcid (13) are deliberately not named here — see the WriteLine-only + // comment in the loop below for why they carry no pinned expectation. + private const uint TumerokId = 7u; + private const uint UndeadId = 11u; + + /// + /// The 9 standard heritages whose UI actually shows clothing controls + /// AND whose default gear resolves with zero ClothingBaseEffects + /// gaps (measured, not the full "clothing UI shown" set — Undead is + /// ALSO clothing-UI-shown but is the one real gap, asserted separately + /// below). Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Tumerok/Lugian/ + /// Empyrean/Penumbraen = every heritage id 1-10 except Gearknight (6). + /// + private static readonly uint[] StandardZeroGapHeritageIds = [1u, 2u, 3u, 4u, 5u, TumerokId, 8u, 9u, 10u]; + + /// + /// Measured (installed EoR dat, both genders, identical order): Undead's + /// default headgear/trousers/shirt/footwear choices' clothing tables, in + /// the factory's own Headgear→Trousers→Shirt→Footwear composition order. + /// ALL FOUR slots miss — not "headgear/trousers/footwear" (a three-slot + /// undercount an earlier draft of this row stated in error). + /// + private static readonly uint[] UndeadMeasuredMissingClothingTableIds = + [0x10000009u, 0x100000F9u, 0x10000001u, 0x10000007u]; + + private static string? ResolveDatDir() + { + string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + string def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + [Fact] + public void EveryHeritageGendersDefaultSelection_ResolvesWithNoMissingDatIds() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.NotEmpty(options.HeritagesById); + var catalog = new ChargenAppearanceCatalog(adapter); + + int composed = 0; + var missingSummaries = new List(); + var baseEffectGapFailures = new List(); + + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey) + { + ChargenAppearanceSelection selection = MakeDefaultSelection(gender); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritage.HeritageId, genderKey, selection, + catalog, catalog, out ChargenAppearanceResult result); + + Assert.True(ok, $"heritage=0x{heritage.HeritageId:X} gender={genderKey} failed to resolve heritage/gender"); + composed++; + + if (result.MissingPalSetIds.Count > 0 || result.MissingClothingTableIds.Count > 0) + { + missingSummaries.Add( + $"heritage={heritage.Name} gender={genderKey}: " + + $"missingPalSets=[{string.Join(",", result.MissingPalSetIds.Select(id => $"0x{id:X8}"))}] " + + $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]"); + } + + _out.WriteLine( + $"heritage={heritage.Name} (0x{heritage.HeritageId:X}) gender={genderKey} setup=0x{result.SetupId:X8}: " + + $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no " + + "ClothingBaseEffects entry for this body setup " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + + // TS-82's pinned measurement — real assertions, not WriteLine-only. + if (StandardZeroGapHeritageIds.Contains(heritage.HeritageId)) + { + if (result.ClothingTablesMissingBaseEffectForSetup.Count != 0) + { + baseEffectGapFailures.Add( + $"heritage={heritage.Name} gender={genderKey}: expected ZERO ClothingBaseEffects " + + $"gaps (a standard heritage with clothing UI shown), measured " + + $"{result.ClothingTablesMissingBaseEffectForSetup.Count}: " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + } + } + else if (heritage.HeritageId == UndeadId) + { + if (!result.ClothingTablesMissingBaseEffectForSetup.SequenceEqual(UndeadMeasuredMissingClothingTableIds)) + { + baseEffectGapFailures.Add( + $"heritage=Undead gender={genderKey}: expected EXACTLY " + + $"[{string.Join(",", UndeadMeasuredMissingClothingTableIds.Select(id => $"0x{id:X8}"))}], measured " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + } + } + // Gearknight/Olthoi/OlthoiAcid: retail hides the clothing UI + // entirely for these three (gmCGAppearancePage::Update + // @0x0047E8F0's SetVisible(0) branches), so a real chargen + // selection never reaches this composer's clothing slots for + // them — no pinned expectation either way, WriteLine above + // is diagnostic only. + } + } + + _out.WriteLine($"composed {composed} heritage/gender selections."); + Assert.True( + missingSummaries.Count == 0, + "Missing dat ids found:\n" + string.Join('\n', missingSummaries)); + Assert.True( + baseEffectGapFailures.Count == 0, + "TS-82 measurement drifted from its pinned expectation:\n" + string.Join('\n', baseEffectGapFailures)); + Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}."); + } + + /// + /// CC6a review fix round F1: retail's Setup-id "unset" sentinel is + /// INVALID_DID (0xFFFFFFFF), not 0 + /// (CharGenState::GetSetupID @ 0x005C5B22). Sweeps EVERY hair + /// style of all 26 heritage/gender combinations and asserts the composed + /// SetupId always resolves to a REAL installed Setup dat entry — proving + /// neither sentinel value, wherever a hair style's AlternateSetup + /// field happens to store one, ever reaches Get<Setup> as a + /// literal id. + /// + [Fact] + public void EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.NotEmpty(options.HeritagesById); + var catalog = new ChargenAppearanceCatalog(adapter); + + int sweptHairStyles = 0; + var unresolvedSetups = new List(); + + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey) + { + for (uint hairStyleIndex = 0; hairStyleIndex < (uint)gender.HairStyles.Count; hairStyleIndex++) + { + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = hairStyleIndex, + SkinShade = 0.5, + }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritage.HeritageId, genderKey, selection, + catalog, catalog, out ChargenAppearanceResult result); + Assert.True(ok); + sweptHairStyles++; + + if (adapter.Get(result.SetupId) is null) + { + unresolvedSetups.Add( + $"heritage={heritage.Name} gender={genderKey} hairStyle={hairStyleIndex}: " + + $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup"); + } + } + + // Every gender is swept even with zero hair styles (still + // exercises the "no hair style selected" default-setup path). + if (gender.HairStyles.Count == 0) + { + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritage.HeritageId, genderKey, + ChargenAppearanceSelection.Default with { SkinShade = 0.5 }, + catalog, catalog, out ChargenAppearanceResult result); + Assert.True(ok); + sweptHairStyles++; + if (adapter.Get(result.SetupId) is null) + { + unresolvedSetups.Add( + $"heritage={heritage.Name} gender={genderKey} (no hair styles): " + + $"composed SetupId=0x{result.SetupId:X8} does not resolve to an installed Setup"); + } + } + } + } + + _out.WriteLine($"swept {sweptHairStyles} hair-style/no-hair-style selections across 26 heritage/gender combinations."); + Assert.True( + unresolvedSetups.Count == 0, + "Composed SetupId(s) that don't resolve to a real installed Setup:\n" + string.Join('\n', unresolvedSetups)); + Assert.True(sweptHairStyles > 26, $"Expected more than 26 swept selections (multiple hair styles per gender), got {sweptHairStyles}."); + } + + /// + /// "Pick the first offered option everywhere, mid shade" — CC6a's own + /// default policy for exercising the factory end-to-end, NOT a claim + /// about retail's own CharGenState default selection (that policy is + /// CC3/CC6b's concern). Every index/shade starts at + /// / + /// and is only set when the gender's own list actually offers an + /// option, so a heritage with e.g. no headgear choices exercises the + /// factory's "slot not selected" path rather than an out-of-range index. + /// + private static ChargenAppearanceSelection MakeDefaultSelection(ChargenGenderOptions gender) + { + const double midShade = 0.5; + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default; + + if (gender.HairStyles.Count > 0) + selection = selection with { HairStyle = 0u }; + if (gender.EyeStrips.Count > 0) + selection = selection with { EyesStrip = 0u }; + if (gender.NoseStrips.Count > 0) + selection = selection with { NoseStrip = 0u }; + if (gender.MouthStrips.Count > 0) + selection = selection with { MouthStrip = 0u }; + if (gender.HairColors.Count > 0) + selection = selection with { HairColor = 0u, HairShade = midShade }; + if (gender.EyeColors.Count > 0) + selection = selection with { EyeColor = 0u }; + + if (gender.Headgears.Count > 0) + selection = selection with { HeadgearStyle = 0u }; + if (gender.Shirts.Count > 0) + selection = selection with { ShirtStyle = 0u }; + if (gender.Pants.Count > 0) + selection = selection with { TrousersStyle = 0u }; + if (gender.Footwear.Count > 0) + selection = selection with { FootwearStyle = 0u }; + + if (gender.ClothingColors.Count > 0) + { + selection = selection with + { + HeadgearColor = 0u, + HeadgearShade = midShade, + ShirtColor = 0u, + ShirtShade = midShade, + TrousersColor = 0u, + TrousersShade = midShade, + FootwearColor = 0u, + FootwearShade = midShade, + }; + } + + return selection with { SkinShade = midShade }; + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs new file mode 100644 index 00000000..cff70aa1 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs @@ -0,0 +1,686 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Hand-built-fixture tests for . +/// Real installed-DAT coverage (every heritage/gender's default selection, +/// verifying no missing PalSet/ClothingTable ids) lives in +/// AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogInstalledDatTests. +/// +public sealed class ChargenAppearanceFactoryTests +{ + private const uint HeritageId = 1u; + private const int GenderKey = 1; + private const uint BodySetupId = 0x0200_0001u; + private const uint AlternateBodySetupId = 0x0200_00FFu; + + private const uint BasePaletteId = 0x0400_0001u; + private const uint SkinPalSetId = 0x0F00_0001u; + private const uint HairColorPalSetId = 0x0F00_0002u; + private const uint EyeColorPaletteId = 0x0400_0099u; // direct palette id, no PalSet indirection. + + private const uint HeadgearClothingTableId = 0x1900_0001u; + private const uint TrousersClothingTableId = 0x1900_0002u; + private const uint ShirtClothingTableId = 0x1900_0003u; + private const uint FootwearClothingTableId = 0x1900_0004u; + + private static ChargenObjDesc MakeObjDesc(uint tag) => new( + 0u, + [], + [new ChargenTextureChange((byte)tag, 0x0500_0000u + tag, 0x0500_1000u + tag)], + [new ChargenAnimPartChange((byte)tag, 0x0100_0000u + tag)]); + + private static ChargenGenderOptions MakeGender(uint alternateHairSetup = 0u, bool baldHairStyle = false) => new( + GenderKey: GenderKey, + Name: "Male", + Scale: 100u, + SetupId: BodySetupId, + SoundTableId: 0x0900_0001u, + IconId: 0x0600_0001u, + BasePaletteId: BasePaletteId, + SkinPalSetId: SkinPalSetId, + PhysicsTableId: 0x0D00_0001u, + MotionTableId: 0x0900_0002u, + CombatTableId: 0x0000_0001u, + BaseObjDesc: MakeObjDesc(0), + HairColors: [HairColorPalSetId], + HairStyles: + [ + new ChargenHairStyle(0x0600_0002u, baldHairStyle, alternateHairSetup, MakeObjDesc(1)), + ], + EyeColors: [EyeColorPaletteId], + EyeStrips: + [ + new ChargenEyeStrip(0x0600_0003u, 0x0600_0004u, MakeObjDesc(2), MakeObjDesc(20)), + ], + NoseStrips: [new ChargenFaceStrip(0x0600_0005u, MakeObjDesc(3))], + MouthStrips: [new ChargenFaceStrip(0x0600_0006u, MakeObjDesc(4))], + Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0x3000_0001u)], + Shirts: [new ChargenGearOption("Shirt", ShirtClothingTableId, 0x3000_0002u)], + Pants: [new ChargenGearOption("Pants", TrousersClothingTableId, 0x3000_0003u)], + Footwear: [new ChargenGearOption("Boots", FootwearClothingTableId, 0x3000_0004u)], + ClothingColors: [7u]); + + private static ChargenOptions MakeOptions(ChargenGenderOptions gender) + { + var heritage = new ChargenHeritageOptions( + HeritageId, "Test", 0x0600_0001u, BodySetupId, BodySetupId, + 180u, 100u, [0], [], + new Dictionary(), [], + new Dictionary { [GenderKey] = gender }); + return new ChargenOptions( + [], + new Dictionary { [HeritageId] = heritage }, + new Dictionary()); + } + + /// One dye choice per clothing table: palette-template id 7, + /// one PalSet, one range (real units 80/16 → packed (10,2)). + private static ChargenClothingTable MakeClothingTable(uint clothingTableId, uint palSetId, uint bodySetupId) + { + var partChanges = new[] { new ChargenAnimPartChange(5, 0x0100_5000u + clothingTableId) }; + var textureChanges = new[] { new ChargenTextureChange(5, 0x0500_5000u, 0x0500_6000u) }; + var baseEffects = new Dictionary + { + [bodySetupId] = new ChargenClothingBaseEffect(partChanges, textureChanges), + }; + var choice = new ChargenClothingSubPaletteChoice( + palSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]); + var templates = new Dictionary + { + [7u] = new ChargenClothingPaletteTemplate([choice]), + }; + return new ChargenClothingTable(baseEffects, templates); + } + + private sealed class FakePalSetSource : IChargenPalSetSource + { + private readonly Dictionary _sets = new(); + public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds); + public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null; + } + + private sealed class FakeClothingTableSource : IChargenClothingTableSource + { + private readonly Dictionary _tables = new(); + public void Add(uint id, ChargenClothingTable table) => _tables[id] = table; + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _tables.TryGetValue(clothingTableId, out var t) ? t : null; + } + + private static (FakePalSetSource pal, FakeClothingTableSource clothing) MakeSources(uint bodySetupId = BodySetupId) + { + var pal = new FakePalSetSource(); + pal.Add(SkinPalSetId, 0x0400_0010u, 0x0400_0011u, 0x0400_0012u); + pal.Add(HairColorPalSetId, 0x0400_0020u, 0x0400_0021u); + var clothingDyePalSetId = 0x0F00_0003u; + pal.Add(clothingDyePalSetId, 0x0400_0030u, 0x0400_0031u); + + var clothing = new FakeClothingTableSource(); + clothing.Add(HeadgearClothingTableId, MakeClothingTable(HeadgearClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(TrousersClothingTableId, MakeClothingTable(TrousersClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(ShirtClothingTableId, MakeClothingTable(ShirtClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(FootwearClothingTableId, MakeClothingTable(FootwearClothingTableId, clothingDyePalSetId, bodySetupId)); + return (pal, clothing); + } + + [Fact] + public void TryCompose_ReturnsFalse_WhenHeritageIsUnknown() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritageId: 999u, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out _); + + Assert.False(ok); + } + + [Fact] + public void TryCompose_ReturnsFalse_WhenGenderIsUnknown() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, genderKey: 999, ChargenAppearanceSelection.Default, + pal, clothing, out _); + + Assert.False(ok); + } + + [Fact] + public void TryCompose_DefaultSelection_ResolvesBodySetupAndUnconditionalSkinSubpalette() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Equal(BodySetupId, result.SetupId); + Assert.Equal(BasePaletteId, result.BasePaletteId); + Assert.Empty(result.MissingPalSetIds); + Assert.Empty(result.MissingClothingTableIds); + + // UnsetShade (-1.0) is out of [0,1], so GetPaletteIndex returns -1 and + // the skin block is skipped for THIS test's default selection — the + // "unconditional" behavior is that the block always RUNS (always + // attempts the PalSet lookup), not that it always emits an entry. + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 0); + // Base body's own ObjDesc still lands (tag 0's texture/anim change). + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 0); + } + + [Fact] + public void TryCompose_SkinShadeSelected_EmitsSkinSubpaletteAtPackedOffsetZeroCountTwentyFour() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { SkinShade = 0.5 }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette skin = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 24); + Assert.Equal(0x0400_0011u, skin.SubPaletteId); // index 1 of 3 at shade 0.5. + } + + [Fact] + public void TryCompose_HairColorSelected_EmitsHairSubpaletteAtPackedOffsetTwentyFourCountEight() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 1.0 }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette hair = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 24 && sp.NumColors == 8); + Assert.Equal(0x0400_0021u, hair.SubPaletteId); // last of the two at shade 1.0. + } + + [Fact] + public void TryCompose_EyeColorSelected_UsesRawPaletteIdDirectlyNoShadeIndirection() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { EyeColor = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette eye = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 32 && sp.NumColors == 8); + Assert.Equal(EyeColorPaletteId, eye.SubPaletteId); + } + + [Fact] + public void TryCompose_HairStyleSelected_AppendsHairObjDescAfterBase() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(0u, (uint)result.ObjDesc.AnimPartChanges[0].PartIndex); // base first. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1); // hair style second. + } + + [Fact] + public void TryCompose_HairStyleWithAlternateSetup_OverridesBodySetupId() + { + ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: AlternateBodySetupId)); + var (pal, clothing) = MakeSources(bodySetupId: AlternateBodySetupId); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(AlternateBodySetupId, result.SetupId); + } + + [Fact] + public void TryCompose_BothSetupSourcesZero_FallsBackToHumanSetupId() + { + ChargenGenderOptions gender = MakeGender() with { SetupId = 0u }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(bodySetupId: 0u); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); + } + + /// + /// CC6a review fix round F1: retail's "unset" sentinel for a Setup id is + /// INVALID_DID (0xFFFFFFFF — CharGenState::GetSetupID @ + /// 0x005C5B22), not 0. A hair style whose AlternateSetup field + /// stores 0xFFFFFFFF must NOT be adopted as the body Setup id — before + /// this fix the factory would hand 0xFFFFFFFF straight to a caller's + /// Get<Setup>, which nulls, and the whole preview build + /// would fail silently. + /// + [Fact] + public void TryCompose_HairStyleAlternateSetupIsInvalidDid_IsTreatedAsUnsetNotAdopted() + { + ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: 0xFFFFFFFFu)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(BodySetupId, result.SetupId); // gender.SetupId, NOT the INVALID_DID sentinel. + } + + /// + /// Companion to : + /// the resolved Setup id can ALSO be stuck at INVALID_DID (rather than 0) + /// when the gender's own SetupId dat field happens to be + /// 0xFFFFFFFF — the fallback to + /// must catch that case too. + /// + [Fact] + public void TryCompose_GenderSetupIdIsInvalidDid_FallsBackToHumanSetupId() + { + ChargenGenderOptions gender = MakeGender() with { SetupId = 0xFFFFFFFFu }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(bodySetupId: 0xFFFFFFFFu); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); + } + + /// + /// 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() + { + ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: false)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + // tag 2 = non-bald eye ObjDesc, tag 20 = bald eye ObjDesc. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u); + } + + [Fact] + public void TryCompose_EyeStripSelected_UsesBaldObjDesc_WhenHairStyleIsBald() + { + ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: true)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u); // tag 20, bald. + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u); // tag 2, non-bald. + } + + [Fact] + public void TryCompose_NoseAndMouthStripsSelected_AppendBothObjDescs() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { NoseStrip = 0u, MouthStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 3); // nose tag. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 4); // mouth tag. + } + + [Fact] + public void TryCompose_AllFourClothingSlotsSelected_AppearInRetailOrderHeadgearTrousersShirtFootwear() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + TrousersStyle = 0u, + ShirtStyle = 0u, + FootwearStyle = 0u, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + // Only the base body's own tag (PartIndex 0) and the four clothing + // slots' PartIndex-5 overrides are present (no hair style/strips + // selected) — asserting the full ordered sequence pins retail's + // Headgear → Trousers → Shirt → Footwear append order directly. + uint[] expectedPartIds = + [ + 0x0100_0000u, // base body tag. + 0x0100_5000u + HeadgearClothingTableId, + 0x0100_5000u + TrousersClothingTableId, + 0x0100_5000u + ShirtClothingTableId, + 0x0100_5000u + FootwearClothingTableId, + ]; + Assert.Equal(expectedPartIds, result.ObjDesc.AnimPartChanges.Select(c => c.PartId).ToArray()); + } + + [Fact] + public void TryCompose_ClothingSlotWithColor_EmitsPartTextureAndDyeSubpalette() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, // gender.ClothingColors[0] = 7u == the fixture's palette-template key. + HeadgearShade = 0.0, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId); + Assert.Contains(result.ObjDesc.TextureChanges, c => c.PartIndex == 5 && c.NewTextureId == 0x0500_6000u); + // Real range (80, 16) packed by /8 => (10, 2). + Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + [Fact] + public void TryCompose_ClothingSlotWithoutColor_SkipsDyeSubpaletteButKeepsPartTextureChanges() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId); + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + /// + /// CC6a review fix round F8: retail's inner subpalette loop + /// (ClothingTable::BuildObjDesc ~0x005A7B24-0x005A7BD3) returns 0 + /// IMMEDIATELY when a PalSet read fails for one choice (~0x005A7B32), + /// aborting every REMAINING choice in that garment's palette template — + /// not merely skipping the failed one and continuing. A two-choice + /// template with the FIRST choice's PalSet missing must therefore emit + /// NEITHER choice's subpalette, even though the second choice's own + /// PalSet is present and would resolve fine on its own. + /// + [Fact] + public void TryCompose_PalSetMissingMidLoop_AbortsRemainingChoicesInThatGarment() + { + const uint missingPalSetId = 0x0F00_00AAu; + const uint presentPalSetId = 0x0F00_00BBu; + + var firstChoice = new ChargenClothingSubPaletteChoice( + missingPalSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]); + var secondChoice = new ChargenClothingSubPaletteChoice( + presentPalSetId, [new ChargenClothingSubPaletteRange(160u, 8u)]); + var baseEffects = new Dictionary + { + [BodySetupId] = ChargenClothingBaseEffect.Empty, + }; + var templates = new Dictionary + { + [7u] = new ChargenClothingPaletteTemplate([firstChoice, secondChoice]), + }; + var table = new ChargenClothingTable(baseEffects, templates); + + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + clothing.Add(HeadgearClothingTableId, table); // override the shared fixture's single-choice table. + pal.Add(presentPalSetId, 0x0400_0055u); // deliberately NOT adding missingPalSetId. + + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, + HeadgearShade = 0.0, + }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(missingPalSetId, result.MissingPalSetIds); + // Real range (160, 8) would pack to (20, 1) if the second choice were + // (incorrectly) still applied after the first choice's miss. + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 20 && sp.NumColors == 1); + // Nothing from EITHER choice's own range landed. + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + /// + /// CC6a review fix round F10: a real dat NumColors of exactly + /// 2048 (256*8) is retail's own "whole palette" value spelled out in + /// real units — it packs to the byte 0 sentinel + /// ('s documented + /// "Length=0 means entire palette") EXPLICITLY, not via an unchecked + /// narrowing coincidence. + /// + [Fact] + public void TryCompose_ClothingRangeNumColorsIsWholePaletteSentinel_PacksToZeroExplicitly() + { + var choice = new ChargenClothingSubPaletteChoice( + 0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2048u)]); + var baseEffects = new Dictionary + { + [BodySetupId] = ChargenClothingBaseEffect.Empty, + }; + var table = new ChargenClothingTable( + baseEffects, + new Dictionary { [7u] = new([choice]) }); + + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + clothing.Add(HeadgearClothingTableId, table); + + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, + HeadgearShade = 0.0, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 0); + } + + /// + /// CC6a review fix round F10: a shape the packed *8 byte convention + /// cannot represent losslessly (not a multiple of 8, and not the 2048 + /// whole-palette sentinel) must THROW rather than silently truncate via + /// an unchecked (byte) cast. + /// + [Fact] + public void TryCompose_ClothingRangeDoesNotFitThePackedByteConvention_Throws() + { + var choice = new ChargenClothingSubPaletteChoice( + 0x0F00_0003u, [new ChargenClothingSubPaletteRange(0u, 2041u)]); // not a multiple of 8, not 2048. + var baseEffects = new Dictionary + { + [BodySetupId] = ChargenClothingBaseEffect.Empty, + }; + var table = new ChargenClothingTable( + baseEffects, + new Dictionary { [7u] = new([choice]) }); + + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + clothing.Add(HeadgearClothingTableId, table); + + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, + HeadgearShade = 0.0, + }; + + Assert.Throws(() => + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out _)); + } + + [Fact] + public void TryCompose_UnknownClothingTableId_IsRecordedAsMissingAndSkipped() + { + ChargenGenderOptions gender = MakeGender(); + gender = gender with + { + Headgears = [new ChargenGearOption("Missing", 0x1900_00FFu, 0x3000_0099u)], + }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(0x1900_00FFu, result.MissingClothingTableIds); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5); + } + + [Fact] + public void TryCompose_UnknownHairColorPalSetId_IsRecordedAsMissingAndSkipped() + { + ChargenGenderOptions gender = MakeGender() with { HairColors = [0x0F00_00FFu] }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 0.5 }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(0x0F00_00FFu, result.MissingPalSetIds); + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 24); + } + + [Fact] + public void TryCompose_BodySetupAbsentFromClothingBaseEffects_IsRecordedButDoesNotThrow() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(bodySetupId: 0x0200_DEADu); // different from the resolved body setup. + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(HeadgearClothingTableId, result.ClothingTablesMissingBaseEffectForSetup); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5); + } + + [Fact] + public void TryCompose_OutOfRangeStyleIndex_IsTreatedAsUnselected() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 999u, EyesStrip = 999u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 2); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs new file mode 100644 index 00000000..ba284e91 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs @@ -0,0 +1,63 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Pins against the exact +/// formula ACE's PaletteSet.GetPaletteID cites as "Taken from +/// acclient.c (PalSet::GetPaletteID)": (int)((count - 0.000001) * shade), +/// clamped to [0, count-1], with an out-of-[0,1] shade (or a +/// non-positive count) returning -1. +/// +public class ChargenPalSetMathTests +{ + [Theory] + [InlineData(5, 0.0, 0)] + [InlineData(5, 1.0, 4)] + [InlineData(5, 0.5, 2)] + [InlineData(1, 0.0, 0)] + [InlineData(1, 1.0, 0)] + public void GetPaletteIndex_matches_the_cited_acclient_formula(int count, double shade, int expected) + { + Assert.Equal(expected, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Theory] + [InlineData(0, 0.5)] + [InlineData(-1, 0.5)] + public void GetPaletteIndex_returns_negative_one_for_non_positive_count(int count, double shade) + { + Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Theory] + [InlineData(5, -0.0001)] + [InlineData(5, 1.0001)] + [InlineData(5, ChargenAppearanceSelection.UnsetShade)] // retail's own "unset" sentinel is out of [0,1]. + public void GetPaletteIndex_returns_negative_one_for_out_of_range_shade(int count, double shade) + { + Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Fact] + public void GetPaletteIndex_never_exceeds_count_minus_one_near_the_upper_bound() + { + // shade == 1.0 exactly must land on the LAST index, not overflow past it — + // the (count - 0.000001) fudge factor exists precisely to guarantee this. + for (int count = 1; count <= 64; count++) + Assert.Equal(count - 1, ChargenPalSetMath.GetPaletteIndex(count, 1.0)); + } + + [Fact] + public void GetPaletteIndex_is_monotonic_non_decreasing_in_shade() + { + const int count = 13; + int previous = -1; + for (double shade = 0.0; shade <= 1.0; shade += 0.01) + { + int index = ChargenPalSetMath.GetPaletteIndex(count, shade); + Assert.True(index >= previous, $"index regressed at shade={shade}"); + previous = index; + } + } +} 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); + } +}