From 55bfd9ca820fdf4695e4fca8b2547fad8f48ca6f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 17:21:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(chargen):=20Campaign=20CC=20slice=20CC6a?= =?UTF-8?q?=20=E2=80=94=20index=E2=86=92ObjDesc=20factory=20+=20preview=20?= =?UTF-8?q?renderer=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers the CC6a foundation half of the chargen 3D preview: the missing index->ObjDesc appearance factory the campaign plan's acdream-seams section named, plus a static-pose offscreen renderer following PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture. Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges). Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces): ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's ObjDesc rebuild in its exact decompiled order - base body, hair style, clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the UI tab order or the wire's field order, both of which differ), eyes (bald-aware), nose, mouth, then the unconditional skin subpalette, hair color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's shade-to-index formula, cross-checked three ways (decomp control flow, ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's identical slider math). ChargenPalSet/ChargenClothingTable are pure projections behind IChargenPalSetSource/IChargenClothingTableSource so the factory itself never touches a dat. Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the cached dat-backed implementation of those two source interfaces, mirroring ChargenTableReader's no-leak discipline. App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and CreatureAppraisalViewportRenderer - no existing rendering file touched. ChargenPreviewCamera carries the four retail-verbatim per-heritage eye profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked against ZoomIn/ZoomOut's identical literals) plus the recovered rotation (3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the decompiler's garbled float literals - the plan's own "measure if it matters" note is resolved, not garbled beyond recovery). Rotation applies to the character model, not the camera, per gmCGAppearancePage::DoRotation. ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself (there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's surface-override algorithm and RetailPaperdollPoseApplicator's held-pose technique, generalized to chargen's per-heritage rest-pose DID. Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail- idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the un-ported clothing Setup-substitution fallback chain costs nothing for the 9 standard heritages with clothing UI, but Undead's default gear choices genuinely lack ClothingBaseEffects coverage for Undead's own body Setup). Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests (installed-DAT-gated, proves a real 34-part Aluvian mesh resolves). Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all pre-existing skips, zero failures, full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 4 +- .../2026-08-15-character-creation-campaign.md | 2 +- .../Rendering/ChargenPreviewCamera.cs | 176 +++++++ .../Rendering/ChargenPreviewEntityBuilder.cs | 258 +++++++++++ .../Rendering/ChargenPreviewRenderer.cs | 80 ++++ .../CharGen/ChargenAppearanceCatalog.cs | 105 +++++ .../CharGen/ChargenAppearanceFactory.cs | 350 ++++++++++++++ .../CharGen/ChargenAppearanceSelection.cs | 52 +++ .../CharGen/ChargenClothingTable.cs | 143 ++++++ src/AcDream.Core/CharGen/ChargenPalSet.cs | 23 + src/AcDream.Core/CharGen/ChargenPalSetMath.cs | 49 ++ .../Rendering/ChargenPreviewCameraTests.cs | 110 +++++ .../ChargenPreviewEntityBuilderTests.cs | 127 +++++ ...argenAppearanceCatalogInstalledDatTests.cs | 155 +++++++ .../CharGen/ChargenAppearanceFactoryTests.cs | 436 ++++++++++++++++++ .../CharGen/ChargenPalSetMathTests.cs | 63 +++ 16 files changed, 2131 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.App/Rendering/ChargenPreviewCamera.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs create mode 100644 src/AcDream.App/Rendering/ChargenPreviewRenderer.cs create mode 100644 src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs create mode 100644 src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs create mode 100644 src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs create mode 100644 src/AcDream.Core/CharGen/ChargenClothingTable.cs create mode 100644 src/AcDream.Core/CharGen/ChargenPalSet.cs create mode 100644 src/AcDream.Core/CharGen/ChargenPalSetMath.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs create mode 100644 tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs create mode 100644 tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 913e52fc..1d9d05d4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -389,10 +389,12 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 50 active rows (TS-83 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview holds a static rest-pose final frame instead of retail's live 30fps idle loop, explicitly staged for CC6b to retire; TS-82 filed 2026-08-15 at Campaign CC slice CC6a — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) to leave Undead's default headgear/trousers/footwear preview unclothed; TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| TS-83 | Chargen 3D preview (Campaign CC slice CC6a foundation): the preview holds a STATIC final-frame rest pose (`ChargenPreviewEntityBuilder.ApplyHeldPose`, retail's `m_didAnimationRest` DID resolution) instead of retail's live 30fps idle loop (`gmCG3DView`'s `m_didAnimation`/`m_didAnimArray` family, driven via `set_sequence_animation`). Deliberately staged, not discovered late: the campaign plan's own CC6 slice row names this exact split ("CC6a static-pose preview... register row for the missing idle loop, CC6b idle animation... retire the row"). | `src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs` (`ApplyHeldPose`); `src/AcDream.App/Rendering/ChargenPreviewRenderer.cs` | Explicitly staged per `docs/plans/2026-08-15-character-creation-campaign.md`'s CC6 slice split; the identical held-pose technique is the paperdoll's own PERMANENT (not staged) design (`RetailPaperdollPoseApplicator.Apply`), so the mechanism itself is proven, only the "hold forever vs. play then hold" choice is temporary here. | The chargen preview shows a motionless character instead of retail's idle sway/breathing loop — cosmetic only; does not affect the composed appearance data (setup id, palette, part/texture overrides) CC6b's page will bind to. | `gmCG3DView` ctor + `::Update @ 0x004EE9D0` (`m_didAnimation`/`m_didAnimArray`/`m_didAnimationRest` DID assignments, pseudo-C ~0x004EE7C6-0x004EE995); `CreatureMode::set_sequence_animation` (idle-loop playback entry point, not yet located precisely — CC6b to find) | +| TS-82 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`: the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default headgear/trousers/footwear choices (both genders) have NO base-effect entry for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage) rather than pervasive. | Undead's default headgear/trousers/footwear preview renders the bare body mesh for those three slots (no clothing part/texture override applied, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | | TS-76 | Five Character-tab rows have no acdream consumer at all (research doc §4.2's own "state-only, no consumer" list, narrowed to the ids NOT already closed by Campaign OP's Group-C re-points): "Display 3D Tooltips" (`ShowTooltips`), "Side By Side Vitals" (`SideBySideVitals`), "Display Spell Durations" (`SpellDuration`), "Advanced Combat Interface" (`AdvancedCombatUI`), "Stay in Chat Mode After Sending a Message" (`StayInChatMode`) — retail renders 3D item tooltips, an alternate side-by-side vitals layout, remaining-duration overlays on enchantment icons, an expanded combat panel, and a chat-input-stays-open behavior respectively; acdream has none of the four rendering surfaces and no chat-input-close-on-send behavior to gate in the first place. | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (the rows wire+store only) | Each needs a real UI/behavior feature built before the option means anything — inventing a stand-in now would be exactly the workaround CLAUDE.md forbids. | Toggling any of the five writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes. | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; `EffectInfoRegion::Update @0x004f1c00`; `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `ChatInterface::HandleEnterKey @0x004f52d0`; `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 2019c2ff..fd27cd37 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -253,6 +253,6 @@ the user gate. | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | | CC4 | — | | | | | CC5 | — | | | | -| CC6a | — | | | | +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | single commit, HEAD of `campaign-cc6a` | PENDING (Opus dual-lens not yet run this session) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — three-way agreement, no guessing:** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c"), ACViewer's identical `ClothingTableList.xaml.cs:97` slider math, AND the decomp's own control-flow shape. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-82 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default headgear/trousers/footwear choices genuinely miss `ClothingBaseEffects` coverage for Undead's own live body Setup on both genders — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests:** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (19 hand-built-fixture cases covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (installed-DAT sweep, all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity). Final counts this session: Core.Tests 4767/1 skip, Content.Tests 146/0 skips, App.Tests 5121/6 skips — all pre-existing skips, zero failures, full solution Release build green. | | CC6b | — | | | | | CC7 | — | | | | diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs new file mode 100644 index 00000000..eeb907ad --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -0,0 +1,176 @@ +using System; +using System.Numerics; +using AcDream.Core.CharGen; + +namespace AcDream.App.Rendering; + +/// +/// Heritage-parameterized camera for the chargen 3D preview +/// (gmCG3DView, Appearance page viewport 0x100003bb / Summary +/// 0x10000406). Retail-exact eye positions, ported from +/// gmCGAppearancePage::Update @ 0x0047E8F0 (pseudo-C ~139037-139114, +/// which sets m_vectTargPosition/m_vectCurPosition per +/// heritage and snaps them together with no tween — CC6a's static preview +/// renders that snapped default, the "zoomed-in" framing) and cross-checked +/// against the IDENTICAL literals in gmCGAppearancePage::ZoomIn @ +/// 0x0047CF00 (pseudo-C ~137618-137638). Direction is always +/// (0,0,0)CreatureMode::SetCameraDirection resets the view +/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention +/// already established for the paperdoll (look +/// straight down +Y, +Z up); every camera position below is used AS the +/// world-space eye directly, matching that camera's approach. +/// +/// +/// Rotation is NOT a camera property. Retail's continuous-rotation +/// button (gmCGAppearancePage::DoRotation @ 0x0047CA80) advances a +/// HEADING applied to the preview CHARACTER (CPhysicsObj::set_heading +/// inside gmCG3DView::Update, pseudo-C ~242088) — the camera's own +/// position/direction never change during a rotation. CC6b's heading +/// parameter therefore belongs on the entity builder +/// (), not here; this class stays a +/// fixed-per-heritage eye, exactly like retail's own camera. +/// +/// +public sealed class ChargenPreviewCamera : ICamera +{ + private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as DollCamera/ChaseCamera. + + private Vector3 _eye; + + public ChargenPreviewCamera(uint heritageId = 0u) + { + _eye = ResolveDefaultEye(heritageId); + } + + /// + /// The camera's current world-space eye. Settable so CC6b can react to a + /// heritage change without reconstructing the camera. + /// + public Vector3 Eye + { + get => _eye; + set => _eye = value; + } + + /// Re-derives for the given heritage id (retail's mHeritageGroup). + public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId); + + /// + /// Retail default (zoomed-in) camera eye per heritage. All four profiles + /// share X=0; only (Y, Z) — the AC world-space forward + /// offset and height — vary. FOUR distinct profiles across the 13 + /// heritages, not five: standard heritages (Aluvian, Gharu'ndim, Sho, + /// Viamontian, Shadowbound, Gearknight, Lugian, Empyrean, Penumbraen, + /// Undead — everything except Tumerok/Olthoi/OlthoiAcid) share the SAME + /// numeric offset as Gearknight's own dedicated branch in the decomp. + /// + public static Vector3 ResolveDefaultEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -1.85000002f, 1.85000002f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -3.04999995f, 2.75f), + (uint)ChargenHeritageGroup.Tumerok => new Vector3(0f, -0.850000024f, 1.64999998f), + _ => new Vector3(0f, -0.550000012f, 1.64999998f), + }; + + /// + /// Retail zoomed-OUT camera eye per heritage + /// (gmCGAppearancePage::ZoomOut @ 0x0047D050, pseudo-C + /// ~137671-137687). CC6a does not implement the zoom button (CC6b) — + /// recorded here as the verified target CC6b's tween will animate + /// toward. Olthoi/OlthoiAcid each keep their own dedicated profile; + /// every other heritage — INCLUDING Tumerok, whose zoomed-IN profile is + /// special-cased but whose zoomed-OUT is not — shares one value. + /// + public static Vector3 ResolveZoomedOutEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -3.79999995f, 1.14999998f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -5.69999981f, 1.64999998f), + _ => new Vector3(0f, -2.5f, 0.95f), + }; + + /// + /// Seconds per 360° revolution for the continuous-rotation button + /// (gmCGAppearancePage::m_dRotationPerSec, ctor pseudo-C + /// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000, + /// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly, + /// no reconstruction needed). Consumed by CC6b's rotation controller as + /// 360f / RotationDegreesPerSecond — NOT applied here; see this + /// class's own doc comment on why rotation is not a camera concern. + /// + public const float RotationSecondsPerRevolution = 3.0f; + + /// + /// Zoom tween duration in seconds + /// (gmCGAppearancePage::DoZoomAnimation @ 0x0047C960's + /// reset-if-invalid default, cross-confirmed by ZoomIn/ZoomOut's + /// own -0.1 sentinel write, which deliberately invalidates + /// m_dAnimDuration so the very next DoZoomAnimation tick + /// resets it to this same value). The campaign plan flagged this + /// constant as decompiler-garbled (both sites split the raw double + /// across two 32-bit stores, and the decompiler mis-renders the LOW + /// dword's store as a bogus float literal instead of raw bits) — it is + /// NOT unrecoverable: reinterpreting each garbled float literal as its + /// own raw 32-bit pattern and pairing it with the store's (clean) high + /// dword reconstructs an exact IEEE-754 double both times. + /// DoZoomAnimation's own reset path: low32 from + /// 4.17232506e-08f reinterpreted = 0x33333333, high32 = + /// 0x3fe33333 (clean) → exactly 0.6. Cross-check via + /// ZoomIn/ZoomOut's sentinel: low32 from + /// -1.58818684e-23f reinterpreted = 0x9999999A, high32 = + /// 0xbfb99999 (clean) → exactly -0.1, the well-known + /// IEEE-754 bit pattern for -0.1 (0xBFB999999999999A) — confirming + /// the reconstruction technique itself, not just this one value. + /// + public const float ZoomTweenDurationSeconds = 0.6f; + + public float FovRadians { get; set; } = MathF.PI / 4f; // retail CreatureMode default, same as DollCamera. + public float Near { get; set; } = 0.1f; + public float Far { get; set; } = 50f; + public float Aspect { get; set; } = 1f; + + public Matrix4x4 View => + Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Up); + + public Matrix4x4 Projection => + Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far); +} + +/// +/// Internal private-viewport adapter, mirroring DollViewportCamera's +/// role for . +/// +internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera +{ + private readonly ChargenPreviewCamera _camera; + + public ChargenPreviewViewportCamera(uint heritageId = 0u) + { + _camera = new ChargenPreviewCamera(heritageId); + } + + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public Vector3 Eye => _camera.Eye; + public float FovRadians + { + get => _camera.FovRadians; + set => _camera.FovRadians = value; + } + public float Near + { + get => _camera.Near; + set => _camera.Near = value; + } + public float Far + { + get => _camera.Far; + set => _camera.Far = value; + } + public float Aspect + { + get => _camera.Aspect; + set => _camera.Aspect = value; + } + public Matrix4x4 View => _camera.View; + public Matrix4x4 Projection => _camera.Projection; +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs new file mode 100644 index 00000000..3b79de88 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -0,0 +1,258 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Meshing; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Builds the static-pose chargen preview from a +/// — the App-layer counterpart to +/// , except this one resolves its OWN +/// MeshRefs from a Setup + the composed ObjDesc rather than receiving +/// already-resolved refs from a live entity (there is no live entity yet; +/// character creation hasn't happened). DAT-touching, unlike +/// 's pure index-agnostic builder — the +/// closest existing precedent for the actual mesh-flatten/apply-changes/ +/// resolve-surface-overrides steps is +/// DatLiveEntityProjectionMaterializer.TryMaterialize, trimmed to +/// what a private, non-animated, non-collision preview scene needs. +/// +internal static class ChargenPreviewEntityBuilder +{ + /// Reserved synthetic guid for the chargen preview clone — + /// same reserved family as + /// (0xDA11D0xx) and CreatureAppraisalEntityBuilder (0xDA11D02x). + public const uint PreviewServerGuid = 0xDA11_D031u; + + /// Reserved render-local entity id — passed in + /// animatedEntityIds by the renderer so a re-dress (a new + /// selection) bypasses WbDrawDispatcher's Tier-1 classification + /// cache, mirroring 's own + /// doc comment. + public const uint PreviewRenderId = 0xDA11_D032u; + + /// + /// Retail's held-pose animation DID enum key, resolved through master + /// map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid + /// — 0x10000005 for every standard heritage (the SAME enum id the + /// paperdoll's own held pose reads), matching + /// gmCG3DView's ctor / ::Update per-heritage + /// m_didAnimationRest assignment (pseudo-C ~0x004EE948, + /// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest + /// DID — the one divergence from the paperdoll, which never needs an + /// Olthoi branch because a live player can't be one. + /// + private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => 0x10000011u, + (uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u, + _ => 0x10000005u, + }; + + /// + /// Builds the preview entity, or null when the resolved body Setup + /// isn't in the dat source (a corrupted/incomplete install — the same + /// failure shape treats + /// as "drop this spawn"). + /// + public static WorldEntity? TryBuild( + IDatReaderWriter dats, + IAnimationLoader animations, + ChargenAppearanceResult appearance, + uint heritageId, + Quaternion heading) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(animations); + ArgumentNullException.ThrowIfNull(appearance); + + Setup? setup = dats.Get(appearance.SetupId); + if (setup is null) + return null; + + var flattened = new List(SetupMesh.Flatten(setup)); + + foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges) + { + if (change.PartIndex < flattened.Count) + flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); + } + + ApplyHeldPose(dats, animations, setup, heritageId, flattened); + + Dictionary>? surfaceOverrides = + ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges); + + var meshRefs = new List(flattened.Count); + for (int partIndex = 0; partIndex < flattened.Count; partIndex++) + { + MeshRef part = flattened[partIndex]; + if (dats.Get(part.GfxObjId) is null) + continue; // matches DatLiveEntityProjectionMaterializer's drawable filter. + + IReadOnlyDictionary? overrides = null; + if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) + overrides = perPart; + + meshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + } + if (meshRefs.Count == 0) + return null; + + PaletteOverride? paletteOverride = null; + if (appearance.ObjDesc.SubPalettes.Count > 0) + { + var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count]; + for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++) + { + ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i]; + ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors); + } + paletteOverride = new PaletteOverride(appearance.BasePaletteId, ranges); + } + + var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count]; + for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++) + { + ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i]; + partOverrides[i] = new PartOverride(change.PartIndex, change.PartId); + } + + return new WorldEntity + { + Id = PreviewRenderId, + ServerGuid = PreviewServerGuid, + SourceGfxObjOrSetupId = appearance.SetupId, + Position = Vector3.Zero, + Rotation = heading, + MeshRefs = meshRefs, + PaletteOverride = paletteOverride, + PartOverrides = partOverrides, + ParentCellId = null, + }; + } + + /// + /// Overwrites every part's transform from the resolved rest pose's + /// FINAL frame — same "hold the settled last frame at zero frame rate" + /// approach as RetailPaperdollPoseApplicator.Apply + /// (RedressCreature @ 0x004A3C22), applied to the FULL + /// setup-part-indexed array (before drawable filtering) so the index + /// alignment holds even if a later part turns out to have a missing + /// GfxObj. No-ops (keeps the default placement frame) when the pose + /// DID or its animation can't be resolved. + /// + private static void ApplyHeldPose( + IDatReaderWriter dats, + IAnimationLoader animations, + Setup setup, + uint heritageId, + List flattened) + { + uint poseDid = ResolvePoseDid(dats, ResolveRestPoseEnum(heritageId)); + if ((poseDid >> 24) != 0x03u) + return; + + Animation? animation = animations.LoadAnimation(poseDid); + if (animation is null || animation.PartFrames.Count == 0) + return; + + var frame = animation.PartFrames[^1]; + for (int index = 0; index < flattened.Count; index++) + { + Vector3 scale = index < setup.DefaultScale.Count ? setup.DefaultScale[index] : Vector3.One; + Vector3 origin = Vector3.Zero; + Quaternion orientation = Quaternion.Identity; + if (index < frame.Frames.Count) + { + origin = frame.Frames[index].Origin; + orientation = frame.Frames[index].Orientation; + } + + Matrix4x4 transform = Matrix4x4.CreateScale(scale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + flattened[index] = new MeshRef(flattened[index].GfxObjId, transform); + } + } + + /// + /// DBCache::GetDIDFromEnumStatic(poseEnum, 7) equivalent — verbatim + /// port of RetailPaperdollPoseApplicator.ResolvePoseDid, + /// parameterized by the target enum key. + /// + private static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum) + { + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; + if (masterDid == 0 + || !dats.Portal.TryGet(masterDid, out var master) + || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) + || !dats.Portal.TryGet(subDid, out var sub)) + { + return 0u; + } + + return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u; + } + + /// + /// Part-index → (old texture id → new texture id) resolution, verbatim + /// port of DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides's + /// algorithm against instead of the + /// wire's CreateObject.TextureChange. + /// + private static Dictionary>? ResolveSurfaceOverrides( + IDatReaderWriter dats, + IReadOnlyList parts, + IReadOnlyList textureChanges) + { + if (textureChanges.Count == 0) + return null; + + var oldToNewByPart = new Dictionary>(); + foreach (ChargenTextureChange change in textureChanges) + { + if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew)) + { + oldToNew = []; + oldToNewByPart.Add(change.PartIndex, oldToNew); + } + oldToNew[change.OldTextureId] = change.NewTextureId; + } + + var result = new Dictionary>(); + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew)) + continue; + + GfxObj? gfx = dats.Get(parts[partIndex].GfxObjId); + if (gfx is null) + continue; + + Dictionary? resolved = null; + foreach (var surfaceQid in gfx.Surfaces) + { + uint surfaceId = (uint)surfaceQid; + Surface? surface = dats.Get(surfaceId); + if (surface is null) + continue; + uint originalTexture = (uint)surface.OrigTextureId; + if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture)) + continue; + + (resolved ??= [])[surfaceId] = newTexture; + } + + if (resolved is not null) + result[partIndex] = resolved; + } + + return result.Count == 0 ? null : result; + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs new file mode 100644 index 00000000..ec6c92c8 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -0,0 +1,80 @@ +using AcDream.App.Rendering.Wb; +using AcDream.App.UI; +using AcDream.Core.Lighting; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Chargen-specific facade over the shared private creature viewport +/// () — CC6a's foundation half of +/// the campaign plan's "chargen preview renderer" deliverable. Mirrors +/// 's shape exactly, with a +/// heading-capable in place of the +/// paperdoll's fixed one. +/// +/// +/// NOT wired here (CC6b, after CC4 merges per the campaign's parallelism +/// contract): mounting into the authored Appearance/Summary viewport ids +/// (0x100003bb / 0x10000406), spin/color-wheel controls, and +/// the rotate/zoom buttons. This class is a standalone, composition-root- +/// agnostic renderer — nothing in AcDream.App/UI/Layout/ or +/// RetailUiRuntime.cs references it yet. +/// +/// +/// +/// Register row (staged deviation, retired by CC6b): retail plays a +/// live 30fps idle loop in the preview +/// (gmCG3DView's m_didAnimation/m_didAnimArray, +/// set_sequence_animation, distinct from the STATIC +/// m_didAnimationRest this class's entity builder uses). CC6a holds +/// the static rest-pose final frame only — see +/// docs/architecture/retail-divergence-register.md. +/// +/// +internal sealed class ChargenPreviewRenderer : + IUiViewportRenderer, + IDisposable +{ + private readonly PrivateEntityViewportRenderer _renderer; + private readonly ChargenPreviewViewportCamera _camera; + + internal ChargenPreviewRenderer( + IWorldPassScope scope, + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frames, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IEntityTextureLifetime textureLifetime, + IWbMeshAdapter meshAdapter, + uint heritageId = 0u) + { + _camera = new ChargenPreviewViewportCamera(heritageId); + _renderer = new PrivateEntityViewportRenderer( + scope, + device, + frames, + dispatcher, + lightUbo, + textureLifetime, + meshAdapter, + ChargenPreviewEntityBuilder.PreviewRenderId, + _camera, + "chargen preview"); + } + + public bool TextureIsBottomUp => _renderer.TextureIsBottomUp; + + /// + /// Re-derives the fixed per-heritage camera eye + /// () — call whenever + /// the selected heritage changes, BEFORE the next . + /// + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity); + + public uint Render(int width, int height) => _renderer.Render(width, height); + + public void Dispose() => _renderer.Dispose(); +} diff --git a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs new file mode 100644 index 00000000..fe1c7579 --- /dev/null +++ b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs @@ -0,0 +1,105 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; +using AcDream.Core.CharGen; +using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable; +using DatPalSet = DatReaderWriter.DBObjs.PalSet; +using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect; +using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette; + +namespace AcDream.Content.CharGen; + +/// +/// DAT-backed / +/// implementation: reads PalSet (0x0F......) and ClothingTable (0x19......) +/// dat objects on demand and projects them into 's +/// pure Core types, matching ChargenTableReader's "no Chorizite leak" +/// discipline for everything it returns. Both lookups cache by dat id — a +/// live preview re-composes on every appearance change, and the same +/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and +/// re-selections within one session. +/// +public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource +{ + private readonly IDatReaderWriter _dats; + private readonly ConcurrentDictionary _palSets = new(); + private readonly ConcurrentDictionary _clothingTables = new(); + + public ChargenAppearanceCatalog(IDatReaderWriter dats) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + } + + public ChargenPalSet? TryGetPalSet(uint palSetId) => + _palSets.GetOrAdd(palSetId, LoadPalSet); + + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _clothingTables.GetOrAdd(clothingTableId, LoadClothingTable); + + private ChargenPalSet? LoadPalSet(uint id) + { + DatPalSet? palSet = _dats.Get(id); + if (palSet is null) + return null; + + var ids = new uint[palSet.Palettes.Count]; + for (int i = 0; i < palSet.Palettes.Count; i++) + ids[i] = palSet.Palettes[i].DataId; + return new ChargenPalSet(Array.AsReadOnly(ids)); + } + + private ChargenClothingTable? LoadClothingTable(uint id) + { + DatClothingTable? table = _dats.Get(id); + if (table is null) + return null; + + var baseEffects = new Dictionary( + table.ClothingBaseEffects.Count); + foreach (var pair in table.ClothingBaseEffects) + baseEffects[pair.Key.DataId] = ProjectBaseEffect(pair.Value.CloObjectEffects); + + var templates = new Dictionary( + table.ClothingSubPalEffects.Count); + foreach (var pair in table.ClothingSubPalEffects) + templates[pair.Key] = ProjectPaletteTemplate(pair.Value.CloSubPalettes); + + return new ChargenClothingTable( + baseEffects.ToFrozenDictionary(), + templates.ToFrozenDictionary()); + } + + private static ChargenClothingBaseEffect ProjectBaseEffect( + IReadOnlyList objectEffects) + { + var partChanges = new List(objectEffects.Count); + var textureChanges = new List(); + foreach (DatCloObjectEffect effect in objectEffects) + { + var partIndex = (byte)effect.Index; + partChanges.Add(new ChargenAnimPartChange(partIndex, effect.ModelId.DataId)); + foreach (var tex in effect.CloTextureEffects) + { + textureChanges.Add(new ChargenTextureChange( + partIndex, tex.OldTexture.DataId, tex.NewTexture.DataId)); + } + } + return new ChargenClothingBaseEffect( + Array.AsReadOnly(partChanges.ToArray()), + Array.AsReadOnly(textureChanges.ToArray())); + } + + private static ChargenClothingPaletteTemplate ProjectPaletteTemplate( + IReadOnlyList subPalettes) + { + var choices = new ChargenClothingSubPaletteChoice[subPalettes.Count]; + for (int i = 0; i < subPalettes.Count; i++) + { + DatCloSubPalette sub = subPalettes[i]; + var ranges = new ChargenClothingSubPaletteRange[sub.Ranges.Count]; + for (int j = 0; j < sub.Ranges.Count; j++) + ranges[j] = new ChargenClothingSubPaletteRange(sub.Ranges[j].Offset, sub.Ranges[j].NumColors); + choices[i] = new ChargenClothingSubPaletteChoice(sub.PaletteSet.DataId, Array.AsReadOnly(ranges)); + } + return new ChargenClothingPaletteTemplate(Array.AsReadOnly(choices)); + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs new file mode 100644 index 00000000..c091471a --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs @@ -0,0 +1,350 @@ +namespace AcDream.Core.CharGen; + +/// +/// The resolved render description +/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies +/// to it (CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0 is +/// retail's equivalent apply step). The three diagnostic lists let callers +/// (and CC6a's installed-DAT test) verify a selection resolved with no +/// missing dat data without needing to re-walk the composition themselves. +/// +/// +/// The body Setup dat id (0x02......) to build the preview mesh from — +/// gender.SetupId, overridden by the selected hair style's +/// AlternateSetup when nonzero (Gear Knight / Undead / Tumerok body +/// variants), falling back to +/// when both are zero (retail: CPhysicsObj::makeObject(setupId)'s own +/// HUMAN_SETUP_ID fallback, gmCG3DView ctor pseudo-C ~0x004EE79D and +/// gmCG3DView::Update ~0x004EEA61). +/// +/// +/// gender.BasePaletteId (retail Sex_CG.BasePalette) — the +/// palette a mesh builder should pass as the entity's base, NOT +/// ObjDesc.PaletteId (retail's own on-disk BaseObjDesc.PaletteId +/// field is unused for this purpose; cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:58, +/// which sets PropertyDataId.PaletteBase from sex.BasePalette +/// directly). +/// +/// +/// The composed subpalette/texture/part-swap deltas, in retail's exact +/// application order (see ). +/// +public sealed record ChargenAppearanceResult( + uint SetupId, + uint BasePaletteId, + ChargenObjDesc ObjDesc, + IReadOnlyList MissingPalSetIds, + IReadOnlyList MissingClothingTableIds, + IReadOnlyList ClothingTablesMissingBaseEffectForSetup); + +/// +/// Index→ObjDesc appearance factory: the missing piece the campaign plan's +/// "acdream seams" section names (Appearance building: DollEntityBuilder.Build +/// is index-agnostic but reads a LIVE entity; chargen needs a new index→dat +/// →ObjDesc factory). Pure — no Chorizite types on this type's public +/// surface, matching CC1's ChargenOptions family; PalSet/ClothingTable +/// dat reads are pushed behind / +/// , whose production implementation +/// (AcDream.Content.CharGen.ChargenAppearanceCatalog) does the actual +/// dat work. +/// +/// +/// Ports gmCG3DView::Update @ 0x004EE9D0's ObjDesc rebuild verbatim, +/// in its EXACT append order (verified against the decompiled control flow, +/// not inferred from the UI's tab order or the wire's field order, both of +/// which differ — see the per-slot XML doc below): +/// +/// +/// Base body (Sex_CG.BaseObjDesc). +/// Hair style overlay (HairStyle_CG.ObjDesc), if selected. +/// Clothing, in retail's own order — Headgear, Trousers, Shirt, +/// Footwear (NOT the UI tab order 5/6/7/8 = headgear/shirt/trousers/ +/// footwear, and NOT the wire field order from CC2's 0xF656 builder, +/// which is also headgear/shirt/trousers/footwear). Each slot applies +/// its ClothingBase part/texture overrides unconditionally, then +/// — only when a color is also selected — its dye subpalette via +/// ClothingTable::BuildObjDesc @ 0x005A7900. +/// Eyes strip overlay (bald variant when the selected hair style's +/// Bald flag is set), if selected. +/// Nose strip overlay, if selected. +/// Mouth strip overlay, if selected. +/// Skin subpalette — UNCONDITIONAL, no "if selected" guard in +/// retail (the decompiled block runs every time, unlike every style/ +/// color slot above and below it, which all gate on retail's +/// 0xFFFFFFFF sentinel). +/// Hair color subpalette, if selected. +/// Eye color subpalette, if selected. +/// +/// +public static class ChargenAppearanceFactory +{ + /// + /// Retail's HUMAN_SETUP_ID fallback (ACViewer.Entity.Enum.SetupConst.HumanMale + /// = 0x02000001; the same constant gmCG3DView's ctor and + /// ::Update fall back to when no valid body Setup is resolvable). + /// + public const uint HumanSetupId = 0x02000001u; + + /// + /// Skin subpalette overlay range, retail's hard-coded literal at + /// gmCG3DView::Update ~0x004EF066-0x004EF07E: real byte offset 0, + /// real color count 192 (0xC0), packed to 's + /// *8 on-disk units as (0, 24). + /// + private const byte SkinRangeOffset = 0; + private const byte SkinRangeNumColors = 24; // 192 / 8 + + /// + /// Hair color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF0FA-0x004EF116: real offset 192 (0xC0), real count 64 (0x40), + /// packed to (24, 8). + /// + private const byte HairRangeOffset = 24; // 192 / 8 + private const byte HairRangeNumColors = 8; // 64 / 8 + + /// + /// Eye color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF15A-0x004EF16E: real offset 256 (0x100), real count 64 + /// (0x40), packed to (32, 8). + /// + private const byte EyeRangeOffset = 32; // 256 / 8 + private const byte EyeRangeNumColors = 8; // 64 / 8 + + /// + /// Composes a preview appearance description for one heritage/gender + + /// selection, or returns false when the heritage/gender itself doesn't + /// resolve (mirrors the Try* convention + /// already uses). Never throws on missing PalSet/ClothingTable data — + /// a miss is recorded in the result's diagnostic lists and that single + /// contribution is skipped, matching retail's own "hash miss → no-op, + /// caller never checks BuildObjDesc's return value" behavior. + /// + public static bool TryCompose( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection, + IChargenPalSetSource palSets, + IChargenClothingTableSource clothingTables, + out ChargenAppearanceResult result) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(palSets); + ArgumentNullException.ThrowIfNull(clothingTables); + + result = default!; + if (!options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + || !heritage.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender)) + { + return false; + } + + var missingPalSets = new List(); + var missingClothingTables = new List(); + var absentBaseEffects = new List(); + + // ── 1. body Setup id ──────────────────────────────────────────── + uint setupId = gender.SetupId; + ChargenHairStyle? hairStyle = null; + if (selection.HairStyle != ChargenAppearanceSelection.Unset + && selection.HairStyle < (uint)gender.HairStyles.Count) + { + hairStyle = gender.HairStyles[(int)selection.HairStyle]; + if (hairStyle.AlternateSetup != 0) + setupId = hairStyle.AlternateSetup; + } + if (setupId == 0) + setupId = HumanSetupId; + + // ── 2. ObjDesc accumulation, retail's exact append order ─────── + var subPalettes = new List(); + var textureChanges = new List(); + var animPartChanges = new List(); + + Append(gender.BaseObjDesc, subPalettes, textureChanges, animPartChanges); + if (hairStyle is not null) + Append(hairStyle.ObjDesc, subPalettes, textureChanges, animPartChanges); + + ComposeClothingSlot( + gender.Headgears, selection.HeadgearStyle, + gender.ClothingColors, selection.HeadgearColor, selection.HeadgearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Pants, selection.TrousersStyle, + gender.ClothingColors, selection.TrousersColor, selection.TrousersShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Shirts, selection.ShirtStyle, + gender.ClothingColors, selection.ShirtColor, selection.ShirtShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Footwear, selection.FootwearStyle, + gender.ClothingColors, selection.FootwearColor, selection.FootwearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + + if (selection.EyesStrip != ChargenAppearanceSelection.Unset + && selection.EyesStrip < (uint)gender.EyeStrips.Count) + { + ChargenEyeStrip strip = gender.EyeStrips[(int)selection.EyesStrip]; + bool bald = hairStyle?.Bald == true; + Append(bald ? strip.BaldObjDesc : strip.ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.NoseStrip != ChargenAppearanceSelection.Unset + && selection.NoseStrip < (uint)gender.NoseStrips.Count) + { + Append(gender.NoseStrips[(int)selection.NoseStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.MouthStrip != ChargenAppearanceSelection.Unset + && selection.MouthStrip < (uint)gender.MouthStrips.Count) + { + Append(gender.MouthStrips[(int)selection.MouthStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + + // ── Skin subpalette: UNCONDITIONAL (no selection gate in retail) ─ + ChargenPalSet? skinPalSet = palSets.TryGetPalSet(gender.SkinPalSetId); + if (skinPalSet is null) + { + missingPalSets.Add(gender.SkinPalSetId); + } + else + { + int skinIndex = ChargenPalSetMath.GetPaletteIndex(skinPalSet.PaletteIds.Count, selection.SkinShade); + if (skinIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + skinPalSet.PaletteIds[skinIndex], SkinRangeOffset, SkinRangeNumColors)); + } + } + + if (selection.HairColor != ChargenAppearanceSelection.Unset + && selection.HairColor < (uint)gender.HairColors.Count) + { + uint hairPalSetId = gender.HairColors[(int)selection.HairColor]; + ChargenPalSet? hairPalSet = palSets.TryGetPalSet(hairPalSetId); + if (hairPalSet is null) + { + missingPalSets.Add(hairPalSetId); + } + else + { + int hairIndex = ChargenPalSetMath.GetPaletteIndex(hairPalSet.PaletteIds.Count, selection.HairShade); + if (hairIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + hairPalSet.PaletteIds[hairIndex], HairRangeOffset, HairRangeNumColors)); + } + } + } + + if (selection.EyeColor != ChargenAppearanceSelection.Unset + && selection.EyeColor < (uint)gender.EyeColors.Count) + { + // Direct Palette id — no PalSet/shade indirection (see ChargenPalSet's doc). + uint eyePaletteId = gender.EyeColors[(int)selection.EyeColor]; + subPalettes.Add(new ChargenSubPalette(eyePaletteId, EyeRangeOffset, EyeRangeNumColors)); + } + + var objDesc = new ChargenObjDesc( + gender.BasePaletteId, + subPalettes.AsReadOnly(), + textureChanges.AsReadOnly(), + animPartChanges.AsReadOnly()); + + result = new ChargenAppearanceResult( + setupId, + gender.BasePaletteId, + objDesc, + missingPalSets.AsReadOnly(), + missingClothingTables.AsReadOnly(), + absentBaseEffects.AsReadOnly()); + return true; + } + + private static void Append( + ChargenObjDesc source, + List subPalettes, + List textureChanges, + List animPartChanges) + { + subPalettes.AddRange(source.SubPalettes); + textureChanges.AddRange(source.TextureChanges); + animPartChanges.AddRange(source.AnimPartChanges); + } + + private static void ComposeClothingSlot( + IReadOnlyList gearOptions, + uint styleIndex, + IReadOnlyList clothingColors, + uint colorIndex, + double shade, + uint bodySetupId, + IChargenClothingTableSource clothingTables, + IChargenPalSetSource palSets, + List subPalettes, + List textureChanges, + List animPartChanges, + List missingClothingTables, + List missingPalSets, + List absentBaseEffects) + { + if (styleIndex == ChargenAppearanceSelection.Unset || styleIndex >= (uint)gearOptions.Count) + return; + + ChargenGearOption gear = gearOptions[(int)styleIndex]; + ChargenClothingTable? table = clothingTables.TryGetClothingTable(gear.ClothingTableId); + if (table is null) + { + missingClothingTables.Add(gear.ClothingTableId); + return; + } + + if (table.BaseEffectsBySetupId.TryGetValue(bodySetupId, out ChargenClothingBaseEffect? baseEffect)) + { + animPartChanges.AddRange(baseEffect.PartChanges); + textureChanges.AddRange(baseEffect.TextureChanges); + } + else + { + absentBaseEffects.Add(gear.ClothingTableId); + } + + if (colorIndex == ChargenAppearanceSelection.Unset || colorIndex >= (uint)clothingColors.Count) + return; + + uint paletteTemplateId = clothingColors[(int)colorIndex]; + if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template)) + return; // retail: hash miss on the palette-template lookup is a silent no-op. + + foreach (ChargenClothingSubPaletteChoice choice in template.Choices) + { + ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId); + if (palSet is null) + { + missingPalSets.Add(choice.PalSetId); + continue; + } + + int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade); + if (index < 0) + continue; + + uint paletteId = palSet.PaletteIds[index]; + foreach (ChargenClothingSubPaletteRange range in choice.Ranges) + { + subPalettes.Add(new ChargenSubPalette( + paletteId, + (byte)(range.Offset / 8), + (byte)(range.NumColors / 8))); + } + } + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs new file mode 100644 index 00000000..efe2d421 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs @@ -0,0 +1,52 @@ +namespace AcDream.Core.CharGen; + +/// +/// The fourteen style/color indices plus the six f64 shades +/// needs to build a preview +/// description — field-for-field the same shape as CC3's +/// AcDream.Runtime.Session.RuntimeCharacterCreationAppearance (and, +/// through it, CharacterCreate.Appearance's wire fields), kept as a +/// SEPARATE type here rather than referenced directly because +/// AcDream.Runtime depends on AcDream.Core and not the other +/// way around. CC6b's job is the trivial field-by-field copy from the +/// Runtime owner's snapshot into this type. / +/// mirror retail's own sentinels exactly (same +/// citations CC3 already recorded): 0xFFFFFFFF for "nothing selected" +/// and the IEEE-754 -1.0 construction-time shade default +/// (CharGenState::Reset @ 0x005C68A0). +/// +public readonly record struct ChargenAppearanceSelection( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairStyle, + uint HairColor, + uint EyeColor, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade) +{ + public const uint Unset = 0xFFFFFFFFu; + public const double UnsetShade = -1.0; + + public static ChargenAppearanceSelection Default { get; } = new( + Unset, Unset, Unset, + Unset, Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + UnsetShade, UnsetShade, UnsetShade, + UnsetShade, UnsetShade, UnsetShade); +} diff --git a/src/AcDream.Core/CharGen/ChargenClothingTable.cs b/src/AcDream.Core/CharGen/ChargenClothingTable.cs new file mode 100644 index 00000000..f46227f9 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenClothingTable.cs @@ -0,0 +1,143 @@ +using System.Collections.Frozen; + +namespace AcDream.Core.CharGen; + +/// +/// One un-resolved dye-shade choice inside a clothing "palette template" +/// (retail's inner CloSubpalEffect array entry, one per +/// ClothingTable::BuildObjDesc @ 0x005A7900 loop iteration; Chorizite +/// projects the identical shape as DatReaderWriter.Types.CloSubPalette +/// — a PaletteSet id plus a list of overlay ranges). Offsets/counts +/// here are the REAL (unpacked) color units read straight off the dat +/// (installed-DAT probe: Aluvian male "Cloth Cap" headgear reads +/// off=2000,n=48 for every one of its 28 palette-template entries) — the +/// *8-packed byte convention only applies to the OUTPUT +/// , converted once at composition time +/// (). +/// +public readonly record struct ChargenClothingSubPaletteRange(uint Offset, uint NumColors); + +/// +/// One resolvable-by-shade colour choice for a clothing palette template: +/// the PalSet id (0x0F......) to resolve via +/// , plus every overlay range +/// to apply once resolved. +/// +public readonly record struct ChargenClothingSubPaletteChoice( + uint PalSetId, + IReadOnlyList Ranges); + +/// +/// One clothing-table "palette template" (retail's CloPaletteTemplate, +/// looked up in ClothingTable::_paletteTemplatesHash by the id +/// CharGenState::GetHeadgearPaletteTemplateID (and its Shirt/Trousers/ +/// Footwear siblings, all at 0x005C38F0-0x005C3980) return — which is itself +/// just a bounds-checked passthrough of Sex_CG.ClothingColors[index]: +/// every one of the four per-slot template-id arrays +/// (headgearPaletteTemplateIDs/shirtPaletteTemplateIDs/ +/// trousersPaletteTemplateIDs/footwearPaletteTemplateIDs) is +/// populated from the SAME single Sex_CG::ClothingColors dat field — +/// there is no per-clothing-slot color list in the dat schema at all. This +/// CONFIRMS (does not merely approximate) register row AP-208's shared-list +/// design in RuntimeCharacterCreationAppearance/ +/// ChargenAppearanceSlot — installed-DAT probe: Aluvian male's +/// ClothingColors = {9,6,4,8,7,5,2,3,13}, and the "Cloth Cap" +/// headgear's ClothingSubPalEffects keys include 2,3,4,5,6,7,8,9,13 — +/// the shared list's raw values ARE the template-id keys, verified live. +/// +public sealed record ChargenClothingPaletteTemplate( + IReadOnlyList Choices) +{ + public static ChargenClothingPaletteTemplate Empty { get; } = + new(Array.Empty()); +} + +/// +/// One body-Setup-specific part/texture override set (retail's +/// ClothingBaseEffect, applied by +/// ClothingBase::ApplyPartAndTextureChanges @ 0x005A8EB0): for each +/// CloObjectEffect, an unconditional +/// (part index → replacement GfxObj) plus every +/// the SAME object effect carries for +/// that part. +/// +public sealed record ChargenClothingBaseEffect( + IReadOnlyList PartChanges, + IReadOnlyList TextureChanges) +{ + public static ChargenClothingBaseEffect Empty { get; } = new( + Array.Empty(), + Array.Empty()); +} + +/// +/// Pure projection of one ClothingTable dat object (0x19......, retail +/// ClothingTable::Unpack / Chorizite +/// DatReaderWriter.DBObjs.ClothingTable). One instance is referenced +/// per — a single garment +/// CHOICE (e.g. "Cloth Cowl") carries its own table covering every body +/// Setup it can be worn on plus every dye choice offered for it. +/// +/// +/// Deliberate scope cut (CC6a) — MEASURED, not just asserted: retail's +/// ClothingTable::BuildObjDesc falls back through a chain of ~8 +/// hard-coded Setup-id substitutions (Umbraen crown/no-crown/void, +/// Penumbraen, Undead skeleton/zombie, Anakshay) when +/// has no direct entry for the requested +/// body Setup. CC6a's composer looks up +/// directly and skips a slot's part/texture contribution on a miss +/// (matching retail's own "hash miss → BuildObjDesc returns failure, caller +/// does not check it, ObjDesc keeps whatever it already had" behavior) +/// rather than porting the substitution chain. The installed-DAT catalog +/// test (ChargenAppearanceCatalogInstalledDatTests) MEASURED this +/// directly across all 26 heritage/gender combinations rather than assuming +/// it: for the 9 standard heritages where retail's own UI actually shows +/// clothing controls (everything except Gear Knight and the two Olthoi +/// variants, which retail hides the clothes button for entirely — +/// gmCGAppearancePage::Update @ 0x0047E8F0's +/// m_pClothesButton->SetVisible(0) branches for +/// mHeritageGroup == 6 and == 0xc || == 0xd), the default +/// gear choices resolve against their own body Setup with ZERO missing +/// coverage. Undead IS a real gap — retail DOES show clothing +/// controls for Undead, but its default headgear/trousers/footwear choices +/// have no entry for either gender's +/// live Setup id (measured: 4 of 4 non-shirt slots miss, on both genders), +/// because Undead's live body Setup IS one of the skeleton/zombie variants +/// the un-ported substitution chain exists to redirect. A live preview for +/// Undead will therefore render its default headgear/trousers/footwear +/// choice with NO part/texture override applied (the underlying body shows +/// through unclothed for those slots) until the substitution chain — or an +/// equivalent per-heritage default-clothing-setup mapping — lands. Filed as +/// a known CC6a limitation for CC6b/a follow-up rather than silently +/// "confirmed unreachable." +/// +/// +public sealed record ChargenClothingTable( + IReadOnlyDictionary BaseEffectsBySetupId, + IReadOnlyDictionary PaletteTemplatesById) +{ + public static ChargenClothingTable Empty { get; } = new( + FrozenDictionary.Empty, + FrozenDictionary.Empty); +} + +/// +/// Resolves a PalSet dat id (0x0F......) to its pure projection. The +/// production implementation (AcDream.Content.CharGen.ChargenAppearanceCatalog) +/// reads and caches the real dat object; this interface keeps +/// free of any Chorizite dependency +/// (unit tests supply a hand-built fake). +/// +public interface IChargenPalSetSource +{ + ChargenPalSet? TryGetPalSet(uint palSetId); +} + +/// +/// Resolves a ClothingTable dat id (0x19......) to its pure projection. +/// Same production/test split as . +/// +public interface IChargenClothingTableSource +{ + ChargenClothingTable? TryGetClothingTable(uint clothingTableId); +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSet.cs b/src/AcDream.Core/CharGen/ChargenPalSet.cs new file mode 100644 index 00000000..e822d92c --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSet.cs @@ -0,0 +1,23 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure projection of a PalSet dat object (0x0F......, retail +/// PalSet::Unpack / Chorizite DatReaderWriter.DBObjs.PalSet): +/// the ordered list of Palette dat ids (0x04......) a shade fraction picks +/// from via . Every appearance +/// color slot that resolves "by shade" — skin (ChargenGenderOptions.SkinPalSetId), +/// hair (ChargenGenderOptions.HairColors[i]), and every clothing +/// dye choice (ChargenClothingSubPaletteChoice.PalSetId) — reads one +/// of these. Eye color is the one exception: retail uses the raw entry +/// from ChargenGenderOptions.EyeColors directly as a Palette id, no +/// PalSet/shade indirection (gmCG3DView::Update pseudo-C ~0x004EF12F; +/// cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:100, +/// which sets EyesPalette straight from sex.EyeColorList[eyeColor] +/// with no GetPaletteID call, unlike the Skin/Hair lines immediately +/// above it). +/// +public sealed record ChargenPalSet(IReadOnlyList PaletteIds) +{ + public static ChargenPalSet Empty { get; } = new(Array.Empty()); +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSetMath.cs b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs new file mode 100644 index 00000000..68cdb042 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs @@ -0,0 +1,49 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's shade→palette-index resolution +/// (PalSet::GetPaletteID @ 0x005AC570, invoked from +/// gmCG3DView::Update @ 0x004EE9D0 for the skin/hair subpalette +/// build and from ClothingTable::BuildObjDesc @ 0x005A7900 for every +/// clothing-slot dye choice). The decompiled body is FPU-elided (the x87 +/// bounds-compare against 0.0/1.0 and the truncating _ftol2() cast +/// lose their operands to the decompiler), but ACE's +/// ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID carries the +/// explicit comment "Taken from acclient.c (PalSet::GetPaletteID)" with the +/// exact formula below — corroborated by the decomp's own control-flow +/// shape (a two-sided FPU compare consistent with a [0,1] bounds +/// check, then one truncating cast) and independently by ACViewer's +/// ClothingTableList.xaml.cs:97 UI slider, which reimplements the +/// identical (count - 0.000001) * shade expression for its own shade +/// preview. Three independent sources agree. +/// +public static class ChargenPalSetMath +{ + /// + /// Resolves a shade fraction to an index into a palette-id list of the + /// given . Returns -1 (retail's + /// INVALID_DID outcome) when is + /// non-positive or falls outside + /// [0.0, 1.0] — including retail's own -1.0 "unset" + /// sentinel (CharGenState::Reset @ 0x005C68A0), which is + /// deliberately out of range so an untouched shade resolves to + /// "nothing," matching retail. Callers should treat -1 as "skip this + /// subpalette contribution" rather than emit a placeholder id. + /// + public static int GetPaletteIndex(int count, double shade) + { + if (count <= 0 || shade < 0.0 || shade > 1.0) + return -1; + + // Truncating cast, exactly as ACE's cited port and the decomp's + // _ftol2() (which truncates toward zero on x86, matching a plain + // C-style (int) cast here since count > 0 and 0 <= shade <= 1 keep + // the product non-negative). + int index = (int)((count - 0.000001) * shade); + if (index < 0) + index = 0; + if (index > count - 1) + index = count - 1; + return index; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs new file mode 100644 index 00000000..1b0280f3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewCameraTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Pins 's retail-verbatim per-heritage +/// eye positions (gmCGAppearancePage::Update @ 0x0047E8F0, +/// cross-checked against the identical literals in ZoomIn/ZoomOut +/// @ 0x0047CF00/0x0047D050) and the zero-yaw/zero-pitch look +/// convention DollCameraTests already established for the shared private +/// viewport. +/// +public class ChargenPreviewCameraTests +{ + [Theory] + [InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Gharundim, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Gearknight, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Undead, 0f, -0.550000012f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -0.850000024f, 1.64999998f)] + [InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -1.85000002f, 1.85000002f)] + [InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -3.04999995f, 2.75f)] + public void ResolveDefaultEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z) + { + Vector3 eye = ChargenPreviewCamera.ResolveDefaultEye(heritageId); + Assert.Equal(x, eye.X, 4); + Assert.Equal(y, eye.Y, 4); + Assert.Equal(z, eye.Z, 4); + } + + [Theory] + [InlineData((uint)ChargenHeritageGroup.Aluvian, 0f, -2.5f, 0.95f)] + [InlineData((uint)ChargenHeritageGroup.Tumerok, 0f, -2.5f, 0.95f)] // ZoomOut has NO Tumerok special case, unlike the zoomed-in default. + [InlineData((uint)ChargenHeritageGroup.Olthoi, 0f, -3.79999995f, 1.14999998f)] + [InlineData((uint)ChargenHeritageGroup.OlthoiAcid, 0f, -5.69999981f, 1.64999998f)] + public void ResolveZoomedOutEye_MatchesRetailPerHeritageLiterals(uint heritageId, float x, float y, float z) + { + Vector3 eye = ChargenPreviewCamera.ResolveZoomedOutEye(heritageId); + Assert.Equal(x, eye.X, 4); + Assert.Equal(y, eye.Y, 4); + Assert.Equal(z, eye.Z, 4); + } + + [Fact] + public void Constructor_DefaultsToStandardHeritageEye_ForUnknownHeritageId() + { + var cam = new ChargenPreviewCamera(heritageId: 0u); + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(0u), cam.Eye); + } + + [Fact] + public void SetHeritage_UpdatesEyeToTheNewHeritagesProfile() + { + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian); + cam.SetHeritage((uint)ChargenHeritageGroup.Olthoi); + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye((uint)ChargenHeritageGroup.Olthoi), cam.Eye); + } + + [Fact] + public void View_LooksStraightDownPlusY_ZeroYawZeroPitch() + { + // Same identity-direction convention DollCameraTests pins for the paperdoll: + // retail SetCameraDirection(0,0,0) resets the view frame to IDENTITY. + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Aluvian) { Aspect = 1f }; + var forward = -new Vector3(cam.View.M13, cam.View.M23, cam.View.M33); + Assert.Equal(0f, forward.X, 4); + Assert.Equal(1f, forward.Y, 4); + Assert.Equal(0f, forward.Z, 4); + } + + [Fact] + public void Eye_RoundTripsThroughViewMatrixInversion() + { + var cam = new ChargenPreviewCamera((uint)ChargenHeritageGroup.Olthoi) { Aspect = 1f }; + Assert.True(Matrix4x4.Invert(cam.View, out var inv)); + Vector3 eye = inv.Translation; + Assert.Equal(cam.Eye.X, eye.X, 3); + Assert.Equal(cam.Eye.Y, eye.Y, 3); + Assert.Equal(cam.Eye.Z, eye.Z, 3); + } + + [Fact] + public void Projection_IsFiniteAndUsesAspect() + { + var cam = new ChargenPreviewCamera { Aspect = 1.5f }; + Assert.True(float.IsFinite(cam.Projection.M11)); + Assert.NotEqual(0f, cam.Projection.M34); + } + + [Fact] + public void RotationSecondsPerRevolution_IsExactlyThreeSeconds() + { + // Raw double bits low32=0x00000000, high32=0x40080000 — no + // reconstruction needed, the decompiler shows this one cleanly. + Assert.Equal(3.0f, ChargenPreviewCamera.RotationSecondsPerRevolution); + } + + [Fact] + public void ZoomTweenDurationSeconds_IsExactlyZeroPointSix() + { + // Recovered by reinterpreting the decompiler's garbled float literal + // as the raw low-32-bit store and pairing it with the (clean) high + // dword; cross-confirmed via the -0.1 sentinel in ZoomIn/ZoomOut + // reconstructing to the well-known IEEE-754 bit pattern for -0.1. + Assert.Equal(0.6f, ChargenPreviewCamera.ZoomTweenDurationSeconds); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs new file mode 100644 index 00000000..8a3f45d3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Content; +using AcDream.Content.CharGen; +using AcDream.Content.Vfx; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Installed-DAT gate for — +/// mirrors 's env-gated skip pattern +/// (no unit-testable pure surface exists here the way +/// has one, because THIS builder's whole job +/// is resolving Setup/GfxObj/Surface/Animation dat data that +/// receives pre-resolved). +/// +public sealed class ChargenPreviewEntityBuilderTests +{ + private readonly ITestOutputHelper _out; + public ChargenPreviewEntityBuilderTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian. + Assert.True(aluvian!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male)); + + var catalog = new ChargenAppearanceCatalog(adapter); + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default with + { + HairStyle = male!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 1u, 1, selection, catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + Assert.Empty(appearance.MissingPalSetIds); + Assert.Empty(appearance.MissingClothingTableIds); + + var animations = new RetailAnimationLoader(adapter); + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, appearance, heritageId: 1u, Quaternion.Identity); + + Assert.NotNull(entity); + Assert.NotEmpty(entity!.MeshRefs); + Assert.Equal(appearance.SetupId, entity.SourceGfxObjOrSetupId); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewServerGuid, entity.ServerGuid); + Assert.Equal(ChargenPreviewEntityBuilder.PreviewRenderId, entity.Id); + Assert.NotNull(entity.PaletteOverride); + Assert.Equal(appearance.BasePaletteId, entity.PaletteOverride!.BasePaletteId); + + _out.WriteLine($"setup=0x{appearance.SetupId:X8} meshRefs={entity.MeshRefs.Count} subPalettes={entity.PaletteOverride.SubPalettes.Count}"); + } + + [Fact] + public void TryBuild_UnknownSetupId_ReturnsNull() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + var animations = new RetailAnimationLoader(adapter); + + var bogusAppearance = new ChargenAppearanceResult( + SetupId: 0x0200_FFFFu, // Not a real installed Setup id. + BasePaletteId: 0u, + ObjDesc: ChargenObjDesc.Empty, + MissingPalSetIds: [], + MissingClothingTableIds: [], + ClothingTablesMissingBaseEffectForSetup: []); + + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, bogusAppearance, heritageId: 1u, Quaternion.Identity); + + Assert.Null(entity); + } + + [Fact] + public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.True(options.TryGetHeritage(12u, out ChargenHeritageOptions? olthoi)); // Olthoi. + Assert.True(olthoi!.GendersByKey.TryGetValue(1, out ChargenGenderOptions? male) + || olthoi.GendersByKey.TryGetValue(2, out male)); + Assert.NotNull(male); + int genderKey = olthoi.GendersByKey.First(kv => ReferenceEquals(kv.Value, male)).Key; + + var catalog = new ChargenAppearanceCatalog(adapter); + var animations = new RetailAnimationLoader(adapter); + + bool composed = ChargenAppearanceFactory.TryCompose( + options, 12u, genderKey, ChargenAppearanceSelection.Default with { SkinShade = 0.5 }, + catalog, catalog, out ChargenAppearanceResult appearance); + Assert.True(composed); + + var entity = ChargenPreviewEntityBuilder.TryBuild( + adapter, animations, appearance, heritageId: 12u, Quaternion.Identity); + + // Just proves the Olthoi branch doesn't throw / silently fall through to + // "no mesh" — the exact pose DID differs internally (0x10000011 vs + // 0x10000005) but both should still resolve a drawable mesh from Olthoi's + // own Setup. + Assert.NotNull(entity); + Assert.NotEmpty(entity!.MeshRefs); + } +} diff --git a/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs new file mode 100644 index 00000000..b4d08248 --- /dev/null +++ b/tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs @@ -0,0 +1,155 @@ +using AcDream.Content.CharGen; +using AcDream.Core.CharGen; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit.Abstractions; + +namespace AcDream.Content.Tests.CharGen; + +/// +/// Installed-DAT gate for + +/// together: for every one of the 13 +/// installed heritages' genders, composes a "pick the first offered option +/// everywhere, mid shade" selection and asserts it resolves with no missing +/// PalSet or ClothingTable dat ids — the CC6a task's explicit acceptance +/// bar ("every heritage/gender's default selection resolves to a complete +/// description with no missing dat ids"). Also records (without asserting +/// zero — see the class doc on 's +/// deliberate scope cut) how many clothing slots have no +/// ClothingBaseEffects entry for their own gender's body Setup, so a +/// future session can see at a glance whether CC6a's decision to skip +/// retail's Setup-substitution fallback chain ever actually costs +/// coverage on the real dat. +/// +public sealed class ChargenAppearanceCatalogInstalledDatTests +{ + private readonly ITestOutputHelper _out; + public ChargenAppearanceCatalogInstalledDatTests(ITestOutputHelper output) => _out = output; + + private static string? ResolveDatDir() + { + string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + string def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + [Fact] + public void EveryHeritageGendersDefaultSelection_ResolvesWithNoMissingDatIds() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + + ChargenOptions options = ChargenTableReader.Load(adapter); + Assert.NotEmpty(options.HeritagesById); + var catalog = new ChargenAppearanceCatalog(adapter); + + int composed = 0; + int absentBaseEffectTotal = 0; + var missingSummaries = new List(); + + foreach (ChargenHeritageOptions heritage in options.HeritagesById.Values) + { + foreach ((int genderKey, ChargenGenderOptions gender) in heritage.GendersByKey) + { + ChargenAppearanceSelection selection = MakeDefaultSelection(gender); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritage.HeritageId, genderKey, selection, + catalog, catalog, out ChargenAppearanceResult result); + + Assert.True(ok, $"heritage=0x{heritage.HeritageId:X} gender={genderKey} failed to resolve heritage/gender"); + composed++; + + if (result.MissingPalSetIds.Count > 0 || result.MissingClothingTableIds.Count > 0) + { + missingSummaries.Add( + $"heritage={heritage.Name} gender={genderKey}: " + + $"missingPalSets=[{string.Join(",", result.MissingPalSetIds.Select(id => $"0x{id:X8}"))}] " + + $"missingClothingTables=[{string.Join(",", result.MissingClothingTableIds.Select(id => $"0x{id:X8}"))}]"); + } + + absentBaseEffectTotal += result.ClothingTablesMissingBaseEffectForSetup.Count; + if (result.ClothingTablesMissingBaseEffectForSetup.Count > 0) + { + _out.WriteLine( + $"heritage={heritage.Name} gender={genderKey} setup=0x{result.SetupId:X8}: " + + $"{result.ClothingTablesMissingBaseEffectForSetup.Count} clothing table(s) with no " + + "ClothingBaseEffects entry for this body setup " + + $"[{string.Join(",", result.ClothingTablesMissingBaseEffectForSetup.Select(id => $"0x{id:X8}"))}]"); + } + } + } + + _out.WriteLine($"composed {composed} heritage/gender selections; {absentBaseEffectTotal} absent-base-effect slots total."); + Assert.True( + missingSummaries.Count == 0, + "Missing dat ids found:\n" + string.Join('\n', missingSummaries)); + Assert.True(composed >= 13, $"Expected at least 13 heritage/gender combinations, composed {composed}."); + } + + /// + /// "Pick the first offered option everywhere, mid shade" — CC6a's own + /// default policy for exercising the factory end-to-end, NOT a claim + /// about retail's own CharGenState default selection (that policy is + /// CC3/CC6b's concern). Every index/shade starts at + /// / + /// and is only set when the gender's own list actually offers an + /// option, so a heritage with e.g. no headgear choices exercises the + /// factory's "slot not selected" path rather than an out-of-range index. + /// + private static ChargenAppearanceSelection MakeDefaultSelection(ChargenGenderOptions gender) + { + const double midShade = 0.5; + ChargenAppearanceSelection selection = ChargenAppearanceSelection.Default; + + if (gender.HairStyles.Count > 0) + selection = selection with { HairStyle = 0u }; + if (gender.EyeStrips.Count > 0) + selection = selection with { EyesStrip = 0u }; + if (gender.NoseStrips.Count > 0) + selection = selection with { NoseStrip = 0u }; + if (gender.MouthStrips.Count > 0) + selection = selection with { MouthStrip = 0u }; + if (gender.HairColors.Count > 0) + selection = selection with { HairColor = 0u, HairShade = midShade }; + if (gender.EyeColors.Count > 0) + selection = selection with { EyeColor = 0u }; + + if (gender.Headgears.Count > 0) + selection = selection with { HeadgearStyle = 0u }; + if (gender.Shirts.Count > 0) + selection = selection with { ShirtStyle = 0u }; + if (gender.Pants.Count > 0) + selection = selection with { TrousersStyle = 0u }; + if (gender.Footwear.Count > 0) + selection = selection with { FootwearStyle = 0u }; + + if (gender.ClothingColors.Count > 0) + { + selection = selection with + { + HeadgearColor = 0u, + HeadgearShade = midShade, + ShirtColor = 0u, + ShirtShade = midShade, + TrousersColor = 0u, + TrousersShade = midShade, + FootwearColor = 0u, + FootwearShade = midShade, + }; + } + + return selection with { SkinShade = midShade }; + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs new file mode 100644 index 00000000..b405a70f --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenAppearanceFactoryTests.cs @@ -0,0 +1,436 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Hand-built-fixture tests for . +/// Real installed-DAT coverage (every heritage/gender's default selection, +/// verifying no missing PalSet/ClothingTable ids) lives in +/// AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogInstalledDatTests. +/// +public sealed class ChargenAppearanceFactoryTests +{ + private const uint HeritageId = 1u; + private const int GenderKey = 1; + private const uint BodySetupId = 0x0200_0001u; + private const uint AlternateBodySetupId = 0x0200_00FFu; + + private const uint BasePaletteId = 0x0400_0001u; + private const uint SkinPalSetId = 0x0F00_0001u; + private const uint HairColorPalSetId = 0x0F00_0002u; + private const uint EyeColorPaletteId = 0x0400_0099u; // direct palette id, no PalSet indirection. + + private const uint HeadgearClothingTableId = 0x1900_0001u; + private const uint TrousersClothingTableId = 0x1900_0002u; + private const uint ShirtClothingTableId = 0x1900_0003u; + private const uint FootwearClothingTableId = 0x1900_0004u; + + private static ChargenObjDesc MakeObjDesc(uint tag) => new( + 0u, + [], + [new ChargenTextureChange((byte)tag, 0x0500_0000u + tag, 0x0500_1000u + tag)], + [new ChargenAnimPartChange((byte)tag, 0x0100_0000u + tag)]); + + private static ChargenGenderOptions MakeGender(uint alternateHairSetup = 0u, bool baldHairStyle = false) => new( + GenderKey: GenderKey, + Name: "Male", + Scale: 100u, + SetupId: BodySetupId, + SoundTableId: 0x0900_0001u, + IconId: 0x0600_0001u, + BasePaletteId: BasePaletteId, + SkinPalSetId: SkinPalSetId, + PhysicsTableId: 0x0D00_0001u, + MotionTableId: 0x0900_0002u, + CombatTableId: 0x0000_0001u, + BaseObjDesc: MakeObjDesc(0), + HairColors: [HairColorPalSetId], + HairStyles: + [ + new ChargenHairStyle(0x0600_0002u, baldHairStyle, alternateHairSetup, MakeObjDesc(1)), + ], + EyeColors: [EyeColorPaletteId], + EyeStrips: + [ + new ChargenEyeStrip(0x0600_0003u, 0x0600_0004u, MakeObjDesc(2), MakeObjDesc(20)), + ], + NoseStrips: [new ChargenFaceStrip(0x0600_0005u, MakeObjDesc(3))], + MouthStrips: [new ChargenFaceStrip(0x0600_0006u, MakeObjDesc(4))], + Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0x3000_0001u)], + Shirts: [new ChargenGearOption("Shirt", ShirtClothingTableId, 0x3000_0002u)], + Pants: [new ChargenGearOption("Pants", TrousersClothingTableId, 0x3000_0003u)], + Footwear: [new ChargenGearOption("Boots", FootwearClothingTableId, 0x3000_0004u)], + ClothingColors: [7u]); + + private static ChargenOptions MakeOptions(ChargenGenderOptions gender) + { + var heritage = new ChargenHeritageOptions( + HeritageId, "Test", 0x0600_0001u, BodySetupId, BodySetupId, + 180u, 100u, [0], [], + new Dictionary(), [], + new Dictionary { [GenderKey] = gender }); + return new ChargenOptions( + [], + new Dictionary { [HeritageId] = heritage }, + new Dictionary()); + } + + /// One dye choice per clothing table: palette-template id 7, + /// one PalSet, one range (real units 80/16 → packed (10,2)). + private static ChargenClothingTable MakeClothingTable(uint clothingTableId, uint palSetId, uint bodySetupId) + { + var partChanges = new[] { new ChargenAnimPartChange(5, 0x0100_5000u + clothingTableId) }; + var textureChanges = new[] { new ChargenTextureChange(5, 0x0500_5000u, 0x0500_6000u) }; + var baseEffects = new Dictionary + { + [bodySetupId] = new ChargenClothingBaseEffect(partChanges, textureChanges), + }; + var choice = new ChargenClothingSubPaletteChoice( + palSetId, [new ChargenClothingSubPaletteRange(80u, 16u)]); + var templates = new Dictionary + { + [7u] = new ChargenClothingPaletteTemplate([choice]), + }; + return new ChargenClothingTable(baseEffects, templates); + } + + private sealed class FakePalSetSource : IChargenPalSetSource + { + private readonly Dictionary _sets = new(); + public void Add(uint id, params uint[] paletteIds) => _sets[id] = new ChargenPalSet(paletteIds); + public ChargenPalSet? TryGetPalSet(uint palSetId) => _sets.TryGetValue(palSetId, out var s) ? s : null; + } + + private sealed class FakeClothingTableSource : IChargenClothingTableSource + { + private readonly Dictionary _tables = new(); + public void Add(uint id, ChargenClothingTable table) => _tables[id] = table; + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _tables.TryGetValue(clothingTableId, out var t) ? t : null; + } + + private static (FakePalSetSource pal, FakeClothingTableSource clothing) MakeSources(uint bodySetupId = BodySetupId) + { + var pal = new FakePalSetSource(); + pal.Add(SkinPalSetId, 0x0400_0010u, 0x0400_0011u, 0x0400_0012u); + pal.Add(HairColorPalSetId, 0x0400_0020u, 0x0400_0021u); + var clothingDyePalSetId = 0x0F00_0003u; + pal.Add(clothingDyePalSetId, 0x0400_0030u, 0x0400_0031u); + + var clothing = new FakeClothingTableSource(); + clothing.Add(HeadgearClothingTableId, MakeClothingTable(HeadgearClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(TrousersClothingTableId, MakeClothingTable(TrousersClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(ShirtClothingTableId, MakeClothingTable(ShirtClothingTableId, clothingDyePalSetId, bodySetupId)); + clothing.Add(FootwearClothingTableId, MakeClothingTable(FootwearClothingTableId, clothingDyePalSetId, bodySetupId)); + return (pal, clothing); + } + + [Fact] + public void TryCompose_ReturnsFalse_WhenHeritageIsUnknown() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, heritageId: 999u, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out _); + + Assert.False(ok); + } + + [Fact] + public void TryCompose_ReturnsFalse_WhenGenderIsUnknown() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, genderKey: 999, ChargenAppearanceSelection.Default, + pal, clothing, out _); + + Assert.False(ok); + } + + [Fact] + public void TryCompose_DefaultSelection_ResolvesBodySetupAndUnconditionalSkinSubpalette() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Equal(BodySetupId, result.SetupId); + Assert.Equal(BasePaletteId, result.BasePaletteId); + Assert.Empty(result.MissingPalSetIds); + Assert.Empty(result.MissingClothingTableIds); + + // UnsetShade (-1.0) is out of [0,1], so GetPaletteIndex returns -1 and + // the skin block is skipped for THIS test's default selection — the + // "unconditional" behavior is that the block always RUNS (always + // attempts the PalSet lookup), not that it always emits an entry. + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 0); + // Base body's own ObjDesc still lands (tag 0's texture/anim change). + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 0); + } + + [Fact] + public void TryCompose_SkinShadeSelected_EmitsSkinSubpaletteAtPackedOffsetZeroCountTwentyFour() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { SkinShade = 0.5 }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette skin = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 0 && sp.NumColors == 24); + Assert.Equal(0x0400_0011u, skin.SubPaletteId); // index 1 of 3 at shade 0.5. + } + + [Fact] + public void TryCompose_HairColorSelected_EmitsHairSubpaletteAtPackedOffsetTwentyFourCountEight() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 1.0 }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette hair = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 24 && sp.NumColors == 8); + Assert.Equal(0x0400_0021u, hair.SubPaletteId); // last of the two at shade 1.0. + } + + [Fact] + public void TryCompose_EyeColorSelected_UsesRawPaletteIdDirectlyNoShadeIndirection() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { EyeColor = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + ChargenSubPalette eye = Assert.Single(result.ObjDesc.SubPalettes, sp => sp.Offset == 32 && sp.NumColors == 8); + Assert.Equal(EyeColorPaletteId, eye.SubPaletteId); + } + + [Fact] + public void TryCompose_HairStyleSelected_AppendsHairObjDescAfterBase() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(0u, (uint)result.ObjDesc.AnimPartChanges[0].PartIndex); // base first. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1); // hair style second. + } + + [Fact] + public void TryCompose_HairStyleWithAlternateSetup_OverridesBodySetupId() + { + ChargenOptions options = MakeOptions(MakeGender(alternateHairSetup: AlternateBodySetupId)); + var (pal, clothing) = MakeSources(bodySetupId: AlternateBodySetupId); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(AlternateBodySetupId, result.SetupId); + } + + [Fact] + public void TryCompose_BothSetupSourcesZero_FallsBackToHumanSetupId() + { + ChargenGenderOptions gender = MakeGender() with { SetupId = 0u }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(bodySetupId: 0u); + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, ChargenAppearanceSelection.Default, + pal, clothing, out ChargenAppearanceResult result); + + Assert.Equal(ChargenAppearanceFactory.HumanSetupId, result.SetupId); + } + + [Fact] + public void TryCompose_EyeStripSelected_UsesNonBaldObjDesc_WhenHairStyleIsNotBald() + { + ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: false)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + // tag 2 = non-bald eye ObjDesc, tag 20 = bald eye ObjDesc. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u); + } + + [Fact] + public void TryCompose_EyeStripSelected_UsesBaldObjDesc_WhenHairStyleIsBald() + { + ChargenOptions options = MakeOptions(MakeGender(baldHairStyle: true)); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 0u, EyesStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0014u); // tag 20, bald. + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_0002u); // tag 2, non-bald. + } + + [Fact] + public void TryCompose_NoseAndMouthStripsSelected_AppendBothObjDescs() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { NoseStrip = 0u, MouthStrip = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 3); // nose tag. + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 4); // mouth tag. + } + + [Fact] + public void TryCompose_AllFourClothingSlotsSelected_AppearInRetailOrderHeadgearTrousersShirtFootwear() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + TrousersStyle = 0u, + ShirtStyle = 0u, + FootwearStyle = 0u, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + // Only the base body's own tag (PartIndex 0) and the four clothing + // slots' PartIndex-5 overrides are present (no hair style/strips + // selected) — asserting the full ordered sequence pins retail's + // Headgear → Trousers → Shirt → Footwear append order directly. + uint[] expectedPartIds = + [ + 0x0100_0000u, // base body tag. + 0x0100_5000u + HeadgearClothingTableId, + 0x0100_5000u + TrousersClothingTableId, + 0x0100_5000u + ShirtClothingTableId, + 0x0100_5000u + FootwearClothingTableId, + ]; + Assert.Equal(expectedPartIds, result.ObjDesc.AnimPartChanges.Select(c => c.PartId).ToArray()); + } + + [Fact] + public void TryCompose_ClothingSlotWithColor_EmitsPartTextureAndDyeSubpalette() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with + { + HeadgearStyle = 0u, + HeadgearColor = 0u, // gender.ClothingColors[0] = 7u == the fixture's palette-template key. + HeadgearShade = 0.0, + }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId); + Assert.Contains(result.ObjDesc.TextureChanges, c => c.PartIndex == 5 && c.NewTextureId == 0x0500_6000u); + // Real range (80, 16) packed by /8 => (10, 2). + Assert.Contains(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + [Fact] + public void TryCompose_ClothingSlotWithoutColor_SkipsDyeSubpaletteButKeepsPartTextureChanges() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.Contains(result.ObjDesc.AnimPartChanges, c => c.PartId == 0x0100_5000u + HeadgearClothingTableId); + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 10 && sp.NumColors == 2); + } + + [Fact] + public void TryCompose_UnknownClothingTableId_IsRecordedAsMissingAndSkipped() + { + ChargenGenderOptions gender = MakeGender(); + gender = gender with + { + Headgears = [new ChargenGearOption("Missing", 0x1900_00FFu, 0x3000_0099u)], + }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(0x1900_00FFu, result.MissingClothingTableIds); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5); + } + + [Fact] + public void TryCompose_UnknownHairColorPalSetId_IsRecordedAsMissingAndSkipped() + { + ChargenGenderOptions gender = MakeGender() with { HairColors = [0x0F00_00FFu] }; + ChargenOptions options = MakeOptions(gender); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairColor = 0u, HairShade = 0.5 }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(0x0F00_00FFu, result.MissingPalSetIds); + Assert.DoesNotContain(result.ObjDesc.SubPalettes, sp => sp.Offset == 24); + } + + [Fact] + public void TryCompose_BodySetupAbsentFromClothingBaseEffects_IsRecordedButDoesNotThrow() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(bodySetupId: 0x0200_DEADu); // different from the resolved body setup. + var selection = ChargenAppearanceSelection.Default with { HeadgearStyle = 0u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.Contains(HeadgearClothingTableId, result.ClothingTablesMissingBaseEffectForSetup); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 5); + } + + [Fact] + public void TryCompose_OutOfRangeStyleIndex_IsTreatedAsUnselected() + { + ChargenOptions options = MakeOptions(MakeGender()); + var (pal, clothing) = MakeSources(); + var selection = ChargenAppearanceSelection.Default with { HairStyle = 999u, EyesStrip = 999u }; + + bool ok = ChargenAppearanceFactory.TryCompose( + options, HeritageId, GenderKey, selection, pal, clothing, out ChargenAppearanceResult result); + + Assert.True(ok); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 1); + Assert.DoesNotContain(result.ObjDesc.AnimPartChanges, c => c.PartIndex == 2); + } +} diff --git a/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs b/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs new file mode 100644 index 00000000..ba284e91 --- /dev/null +++ b/tests/AcDream.Core.Tests/CharGen/ChargenPalSetMathTests.cs @@ -0,0 +1,63 @@ +using AcDream.Core.CharGen; + +namespace AcDream.Core.Tests.CharGen; + +/// +/// Pins against the exact +/// formula ACE's PaletteSet.GetPaletteID cites as "Taken from +/// acclient.c (PalSet::GetPaletteID)": (int)((count - 0.000001) * shade), +/// clamped to [0, count-1], with an out-of-[0,1] shade (or a +/// non-positive count) returning -1. +/// +public class ChargenPalSetMathTests +{ + [Theory] + [InlineData(5, 0.0, 0)] + [InlineData(5, 1.0, 4)] + [InlineData(5, 0.5, 2)] + [InlineData(1, 0.0, 0)] + [InlineData(1, 1.0, 0)] + public void GetPaletteIndex_matches_the_cited_acclient_formula(int count, double shade, int expected) + { + Assert.Equal(expected, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Theory] + [InlineData(0, 0.5)] + [InlineData(-1, 0.5)] + public void GetPaletteIndex_returns_negative_one_for_non_positive_count(int count, double shade) + { + Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Theory] + [InlineData(5, -0.0001)] + [InlineData(5, 1.0001)] + [InlineData(5, ChargenAppearanceSelection.UnsetShade)] // retail's own "unset" sentinel is out of [0,1]. + public void GetPaletteIndex_returns_negative_one_for_out_of_range_shade(int count, double shade) + { + Assert.Equal(-1, ChargenPalSetMath.GetPaletteIndex(count, shade)); + } + + [Fact] + public void GetPaletteIndex_never_exceeds_count_minus_one_near_the_upper_bound() + { + // shade == 1.0 exactly must land on the LAST index, not overflow past it — + // the (count - 0.000001) fudge factor exists precisely to guarantee this. + for (int count = 1; count <= 64; count++) + Assert.Equal(count - 1, ChargenPalSetMath.GetPaletteIndex(count, 1.0)); + } + + [Fact] + public void GetPaletteIndex_is_monotonic_non_decreasing_in_shade() + { + const int count = 13; + int previous = -1; + for (double shade = 0.0; shade <= 1.0; shade += 0.01) + { + int index = ChargenPalSetMath.GetPaletteIndex(count, shade); + Assert.True(index >= previous, $"index regressed at shade={shade}"); + previous = index; + } + } +}