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