fix(chargen): Campaign CC gate round 1 Batch G — real color wheel (DoColorSpots/DoGradDisk color rendering)

R2-5: retail's gmCGAppearancePage::DoColorSpots/SetSelection/DoGradDisk
paint the nine color swatches and the gradient disc with a real,
computed representative color (PalSet-averaged for Hair/Nose+Mouth+
Skin/Headgear/Shirt/Trousers/Footwear at fixed sample indices
0xd0/0xb0/0x520; direct-Palette for Eyes at 0x103), not the static
authored art acdream showed before this batch.

Ports the full palette-to-RGB pipeline: a new pure Core resolver
(ChargenSwatchColorResolver + IChargenPaletteColorSource) backed by a
new ChargenAppearanceCatalog.TryGetColor reading real Palette dat
objects, pinned against the installed EoR dat. CharacterCreationAppearancePage
recomputes all nine swatches + the gradient disc's tint on every
refresh (part/color/heritage change) and paints them through a new
ChargenSwatchColorTile overlay child — a flat-color-fill approximation
of retail's actual recolored-sprite blit, since neither UiButton
(sealed) nor UiDatElement exposes a per-instance sprite tint today.

Two STOPPED items remain outside this batch's file contract before the
mechanism is visually live: (1) wiring PalSetSource/ClothingTableSource/
PaletteColorSource from CharacterCreationUiController.cs (mirrors the
existing PreviewControl seam); (2) a small additive Tint property on
UiButton/UiDatElement for a byte-true recolor instead of the flat fill.
Also ports Nose/Mouth/Skin's single non-interactive representative
swatch, beyond AP-216/AP-217's original six-part scope.

Register AP-216/AP-217 rewritten (not retired — the two STOPPED items
keep them open). Tests: 11 new Core, 6 new Content live-DAT, 8 new
App-layer fixture. App suite 5321/3 -> 5329/3, Runtime 1735/0
unchanged, zero regressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 14:16:14 +02:00
parent 2ad805469d
commit 834c2547a9
10 changed files with 1558 additions and 29 deletions

View file

@ -396,8 +396,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` |
| 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-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED; RE-NARROWED 2026-08-16 at Batch C fix (GF-6/AP-218) — the "1-based ordinal" framing of item 2 is now STALE and replaced below.** What CLOSED at Batch B: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest. What CLOSED at Batch C: `SetStyleSpinLabel`'s 1-based-ordinal substitution is GONE — `RefreshSpinCaptions` now writes retail's own heritage-flavored STATIC caption (see AP-218, RETIRED). **Still open (RESTATED, not the same gap the ordinal covered):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) now show the SAME static caption regardless of which style is selected — retail's own per-choice visual feedback there is an ICON THUMBNAIL this port still doesn't render (no icon-texture pipeline is wired to ANY chargen widget); the live 3D preview is the player's only feedback for which style is currently active. The four clothing spins (headgear/shirt/trousers/footwear) show a real name via `ChargenGearOption.Name` and have no icon gap. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED Batch B; `RefreshSpinCaptions`, static-caption-only, icon gap still open) | An icon-texture pipeline for the four icon-only spins is new UI infrastructure this round's scope doesn't otherwise need; the static caption alone is retail-faithful for the TEXT half. | A pixel-level side-by-side against retail would show no icon thumbnail next to the four icon-only spins' caption (cosmetic gap only — the caption text itself is now byte-correct, and the live 3D preview still shows the actual selection). A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) |
| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. **What CLOSED:** the "beyond the count" half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides (`Visible=false`) any swatch index at or past the current part's own `ColorCount`, the acdream equivalent of retail's blank blit. **Still open:** the "actual color" half — acdream's swatches still show only their authored (static) DAT art regardless of which color they individually represent; painting each swatch with its own computed color needs a PalSet/Palette-id -> RGB resolution pipeline no chargen page currently reads DAT palette pixels through at runtime (new UI infrastructure this batch judged disproportionate to add alongside its ~10 other fixes). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — hides beyond-count swatches, CLOSED; still sets no per-swatch color, OPEN) | The nine swatches already reach the correct SELECTION semantics AND the correct beyond-count visibility through existing `UiButton`/`UiElement.Visible` primitives; painting each swatch with a computed color needs a genuinely new palette-to-RGB render path this batch's scope didn't otherwise need. | A side-by-side against retail shows every VALID swatch drawing the SAME authored art regardless of which color it represents — a cosmetic gap only now (the beyond-count "stuck visibly on" gap that used to mislead a player about how many real choices existed is closed). | `gmCGAppearancePage::DoColorSpots @0x0047d850` |
| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes. **What CLOSED:** the Eyes-blank half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides the GradCircle when the current part is Eyes, the acdream equivalent of the blank "grad plug" blit. **Still open:** the gradient-graphic TINT half — acdream still never repaints the GradCircle with the current part's color; that composite (`Blit_Multiply` against `m_pGradGraphic`/`m_pGradPlug`) needs the SAME palette-to-RGB resolution pipeline AP-216's still-open half needs, so it stays open for the same reason. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls` now hides the GradCircle for Eyes, CLOSED; still never repaints it for any other part, OPEN) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint is genuinely new render infrastructure, same as AP-216's open half. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color for any part OTHER than Eyes (Eyes now correctly blanks) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) |
| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1); PARTIALLY CLOSED 2026-08-16 at Batch C (the beyond-count half); REWRITTEN 2026-08-16 at the Campaign CC gate round 1 Batch G fix (R2-5) — the "actual color" half is now IMPLEMENTED AND TESTED, with two narrow STOPPED items outside this batch's file contract before it is visually live.** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed in `SetSelection @0x0047e260` into `m_tColorWheel[i].iRed/iGreen/iBlue` via `ClientCharGenState::GetColorFromPal @0x00563990`, a direct `Palette::get_color32`/`ARGB[index]` read at a fixed per-part sample index — `0xd0` Hair, `0xb0` Nose/Mouth/Skin, `0x103` Eyes, `0x520` Headgear/Shirt/Trousers/Footwear) and blits blank art for any swatch beyond the current part's real color count. **What CLOSED at Batch C:** the "beyond the count" half (`Visible=false` past `ColorCount`). **What Batch G ADDS:** the full palette-to-RGB pipeline — a new pure Core resolver (`ChargenSwatchColorResolver`: PalSet-averaged shape for Hair/Nose+Mouth+Skin/Headgear/Shirt/Trousers/Footwear, direct shape for Eyes, plus the clothing-swatch PalSet lookup through the CURRENTLY EQUIPPED garment's own ClothingTable) backed by a new `ChargenAppearanceCatalog.TryGetColor` (Content) reading real Palette dat objects, pinned against the installed EoR dat (`ChargenAppearanceCatalogColorTests` — e.g. Aluvian male Eye swatch 0 measures RGB(15,63,93), the shared skin PalSet measures a plausible RGB(182,148,118) flesh tone). `CharacterCreationAppearancePage` now computes all nine swatches' colors on every refresh (part change / color change / heritage change — `CharacterCreationAppearancePageSwatchColorTests`) and paints them via a new `ChargenSwatchColorTile` child element added on top of each swatch button. **Two STOPPED items remain, both outside this batch's file contract:** (1) the new `PalSetSource`/`ClothingTableSource`/`PaletteColorSource` late-bound properties (mirroring the existing `PreviewControl` seam) are never assigned by the composition root — until `CharacterCreationUiController.cs` wires a `ChargenAppearanceCatalog` instance into them (a 3-line addition, same shape as the existing `AppearancePreviewControl` wiring), the mechanism stays fully inert and every swatch shows ONLY its authored static art, exactly like before this batch (`UnwiredSources_LeaveEveryTileInvisible` pins this explicitly). (2) `ChargenSwatchColorTile` paints a FLAT color fill (`UiRenderContext.DrawFill`), not a genuine recolored sprite — neither `UiButton` (sealed) nor `UiDatElement` exposes a per-instance `Tint` on its existing `DrawSprite` calls (which DO already carry a `Vector4 tint` parameter the retained-UI shader multiplies against, matching retail's own `Blit_Multiply`); adding one is a small additive change to those two shared widget files this batch does not make. | `src/AcDream.Core/CharGen/ChargenSwatchColor.cs` + `ChargenSwatchColorResolver.cs` (new); `src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs` (`TryGetColor`, new); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`ComputeSwatchColors` + the swatch loop, now paints real color OR stays inert without the wiring); `src/AcDream.App/UI/Layout/ChargenSwatchColorTile.cs` (new) | Everything reachable inside this batch's file contract (Core resolver, Content palette read, the App page's own computation + rendering primitive) is fully implemented and tested; the two remaining gaps are BOTH shared-file edits (composition-root wiring; a widget Tint property) outside that contract, reported as STOPPED items rather than worked around. | Until the STOPPED composition-root wiring lands, a user still sees the pre-Batch-G static swatches (this batch changes nothing observable on its own). Once wired, every VALID swatch will show a flat-color patch at its own computed RGB rather than retail's recolored dot-shaped sprite — correct COLOR, approximated SHAPE, until the second STOPPED item (the widget Tint property) also lands. | `gmCGAppearancePage::DoColorSpots @0x0047d850`; `gmCGAppearancePage::SetSelection @0x0047e260`; `ClientCharGenState::GetColorFromPal @0x00563990`; `Palette::get_color32 @0x0053e050`; `CharGenState::StoreColorInformation @0x005c44d0`; `CharGenState::SetHeadgearStyle @0x005c5350` |
| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 (R3); PARTIALLY CLOSED 2026-08-16 at Batch C (the Eyes-blank half); REWRITTEN 2026-08-16 at the Campaign CC gate round 1 Batch G fix (R2-5) — the gradient-TINT half is now IMPLEMENTED AND TESTED, same two STOPPED items as AP-216 (they share the same underlying pipeline and rendering primitive).** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch has NO case for the GradCircle (`0x1000030e`) — it is not a click target. `DoGradDisk @0x0047da90` is PAINT-only, called from `SetColor`'s tail (`@0x0047de18`, AFTER `m_iCurColor` is updated) and from `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it tints the gradient graphic with `m_tColorWheel[m_iCurColor]`'s OWN color for every part except Eyes, or blits the blank "grad plug" for Eyes. **What CLOSED at Batch C:** the Eyes-blank half. **What Batch G ADDS:** the tint half, through the SAME `ChargenSwatchColorResolver`/`ChargenSwatchColorTile` machinery AP-216 now has — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` picks the color at the CURRENTLY SELECTED swatch index (index 0, unconditionally, for Nose/Mouth/Skin — retail hard-codes `eyeColor = 0` for those three cases in `SetSelection`) and paints the GradCircle's own tile with it; Eyes stays permanently untinted (`EyesPart_GradientDiscTileStaysBlank`), and the two STOPPED items from AP-216 (composition-root wiring; a genuine `UiButton`/`UiDatElement` sprite-tint property in place of the current flat-fill approximation) block this half from being visually live for the identical reason. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s GradCircle tint block; `_gradCircleTile`) | Same rationale as AP-216 — the full data pipeline is in place and tested; only the two shared-file STOPPED items (outside this batch's contract) remain before either half renders on screen. | Same STOPPED-item gating as AP-216: no observable change until the composition-root wiring lands; once wired, the disc shows a flat tint rather than retail's recolored gradient graphic until the widget Tint property also lands. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) |
| AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) |
| AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) |
| AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) |

View file

@ -37,7 +37,42 @@ acdream GradCircle vs retail's color wheel).
disc) where retail shows the gradient wheel + gold swatch dots that
CHANGE per selected part — the user's gate PROMOTES AP-216/AP-217's
remaining halves (real palette-color swatch rendering + gradient tint)
from partial-closed to must-port.
from partial-closed to must-port. **CODE-COMPLETE at Batch G (2026-08-16),
register AP-216/AP-217 rewritten (not retired — see their own rows):**
the retail mechanism (`gmCGAppearancePage::DoColorSpots @0x0047d850` /
`SetSelection @0x0047e260` / `DoGradDisk @0x0047da90`) is fully re-derived
and ported — a new pure Core resolver
(`AcDream.Core.CharGen.ChargenSwatchColorResolver`) computes each of the
nine swatches' representative RGB (PalSet-averaged for Hair/Nose+Mouth+
Skin/Headgear/Shirt/Trousers/Footwear at retail's own fixed sample
indices `0xd0`/`0xb0`/`0x520`, direct-Palette for Eyes at `0x103`) backed
by a new `ChargenAppearanceCatalog.TryGetColor` reading real Palette dat
objects, pinned against the installed EoR dat
(`ChargenAppearanceCatalogColorTests` — e.g. Aluvian male's shared skin
PalSet measures a plausible flesh-tone RGB(182,148,118)).
`CharacterCreationAppearancePage` recomputes all nine swatches + the
gradient disc's tint on every refresh (part change / color change /
heritage change, `CharacterCreationAppearancePageSwatchColorTests`), and
paints them through a new `ChargenSwatchColorTile` overlay element.
**Two STOPPED items block this from being visually live**, both outside
Batch G's file contract: (1) the new `PalSetSource`/`ClothingTableSource`/
`PaletteColorSource` late-bound seams (mirroring the existing
`PreviewControl` pattern) are never assigned by the composition root
(`CharacterCreationUiController.cs`) — until wired, the mechanism stays
fully inert, matching PRE-Batch-G behavior exactly; (2) the rendering
primitive is a flat-color-fill approximation of retail's actual
recolored-sprite blit — neither `UiButton` (sealed) nor `UiDatElement`
exposes a per-instance sprite `Tint`, though the retained-UI sprite
pipeline's `DrawSprite` already carries the `Vector4 tint` multiply
retail's own `Blit_Multiply` needs; adding that property is a small,
precisely-specified addition to those two shared widget files for the
lead to sequence. Nose/Mouth/Skin (retail's own non-interactive single
representative swatch, `SetSelection`'s hard-coded `var_1e0 = 1`) is ALSO
ported, beyond AP-216/AP-217's original six-part scope. Tests: 11 new
Core (`ChargenSwatchColorResolverTests`), 6 new Content live-DAT
(`ChargenAppearanceCatalogColorTests`), 8 new App-layer fixture
(`CharacterCreationAppearancePageSwatchColorTests`) — App suite
5321/3 -> 5329/3, Runtime 1735/0 unchanged, zero regressions.
- **R2-6: Town description text misaligned** — R2-1 family.
- **R2-7: Summary — (a) text misaligned (R2-1); (b) the summary OVERVIEW
listbox is missing its scrollbar; (c) the how-to box's scrollbar

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.CharGen;
using AcDream.Runtime;
@ -69,6 +70,24 @@ namespace AcDream.App.UI.Layout;
/// trousers/footwear) DO carry a real <see cref="ChargenGearOption.Name"/>
/// and show it directly.
/// </para>
///
/// <para>
/// <b>The real color wheel (Campaign CC gate round 1 Batch G, R2-5,
/// register AP-216/AP-217):</b> retail's <c>DoColorSpots @0x0047d850</c> /
/// <c>DoGradDisk @0x0047da90</c> paint each swatch and the gradient disc
/// with an ACTUAL representative color sampled from the real DAT palette
/// data (<c>AcDream.Core.CharGen.ChargenSwatchColorResolver</c> ports the
/// computation — see its own doc for the two color-source shapes and the
/// clothing PalSet lookup). <see cref="PalSetSource"/>/
/// <see cref="ClothingTableSource"/>/<see cref="PaletteColorSource"/> are
/// late-bound composition seams (same pattern as <see cref="PreviewControl"/>)
/// a DAT-backed catalog wires in after construction; the <see cref="ChargenSwatchColorTile"/>
/// children painted over each swatch/the gradient disc are this batch's
/// rendering primitive — see that class's own doc for why it is a flat
/// color fill (a documented approximation of retail's actual recolored-
/// sprite blit) and the STOPPED shared-file edit that would upgrade it to
/// a genuine texture tint.
/// </para>
/// </summary>
internal sealed class CharacterCreationAppearancePage : IDisposable
{
@ -176,6 +195,16 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
private readonly UiButton? _zoomOut;
private readonly UiElement? _gradCircle;
/// <summary>R2-5: one flat-color tile per swatch, added as an EXTRA
/// child of the swatch it decorates (see <see cref="ChargenSwatchColorTile"/>'s
/// own doc) — null wherever the matching <see cref="_swatches"/> entry
/// itself is null (nothing to attach to).</summary>
private readonly ChargenSwatchColorTile?[] _swatchColorTiles = new ChargenSwatchColorTile?[SwatchIds.Length];
/// <summary>R2-5: the gradient disc's own tint tile, an extra child of
/// <see cref="_gradCircle"/>.</summary>
private readonly ChargenSwatchColorTile? _gradCircleTile;
private Choice _currentChoice = Choice.Face;
private Part _currentPart = Part.Hair;
private bool _eyesArrowsDisabled;
@ -186,6 +215,24 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
/// page cannot receive the real renderer at construction time.</summary>
internal IChargenPreviewControl? PreviewControl { get; set; }
/// <summary>
/// R2-5 late-bound seams (same pattern as <see cref="PreviewControl"/>
/// above) for the real color-wheel mechanism — null (the default)
/// leaves every swatch/the gradient disc showing ONLY its authored
/// static art, i.e. this page's pre-Batch-G behavior, until a
/// composition root supplies a DAT-backed
/// <c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c> (which
/// already implements all three interfaces) for these three
/// properties, mirroring how <see cref="PreviewControl"/> itself gets
/// wired in from outside this class. STOPPED (Batch G): that
/// assignment is a 3-line addition to
/// <c>CharacterCreationUiController.cs</c>, outside this batch's file
/// contract — see the batch's handoff notes.
/// </summary>
internal IChargenPalSetSource? PalSetSource { get; set; }
internal IChargenClothingTableSource? ClothingTableSource { get; set; }
internal IChargenPaletteColorSource? PaletteColorSource { get; set; }
/// <summary>The authored viewport (<c>0x100003bb</c>) — the composition
/// root assigns its <c>Renderer</c> once the graphics backend exists,
/// mirroring the paperdoll's own late <c>viewport.Renderer = ...</c>
@ -233,6 +280,18 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
int index = i;
swatch.OnClick = () => SelectColor(index);
_swatches[i] = swatch;
// R2-5: an extra CHILD tile, sized to exactly cover the
// swatch's own face — see ChargenSwatchColorTile's own doc for
// why this is a flat fill rather than a recolored sprite, and
// for why ClickThrough there keeps this from ever swallowing
// the swatch's own click.
var tile = new ChargenSwatchColorTile
{
Left = 0f, Top = 0f, Width = swatch.Width, Height = swatch.Height,
};
swatch.AddChild(tile);
_swatchColorTiles[i] = tile;
}
for (int i = 0; i < SwatchOverlayIds.Length; i++)
@ -243,6 +302,14 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
_shadeScroll.ScalarChanged = SetShadeFromScalar;
_gradCircle = Find<UiElement>(pageRoot, GradCircleId);
if (_gradCircle is not null)
{
_gradCircleTile = new ChargenSwatchColorTile
{
Left = 0f, Top = 0f, Width = _gradCircle.Width, Height = _gradCircle.Height,
};
_gradCircle.AddChild(_gradCircleTile);
}
Viewport = Find<UiViewport>(pageRoot, ViewportId);
@ -679,36 +746,71 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
overlay.Visible = colorSlot is not null && currentColor == (uint)i;
}
// AP-216 (Campaign CC gate round 1 Batch C, PARTIAL): retail's
// DoColorSpots @0x0047d850 blits ACTUAL-color art for each valid
// swatch and BLANK art for any swatch beyond the current part's
// real color count. Painting each swatch with its own represented
// color needs a PalSet/Palette-id -> RGB resolution pipeline this
// batch does not add (no chargen page currently reads DAT palette
// pixels at runtime) — register AP-216 stays open for that half.
// This ships the cheap, fully-evidenced half: hiding a swatch a
// part's color list doesn't actually have (closest faithful
// rendering the existing pipeline supports — Visible=false is the
// acdream equivalent of "blit nothing").
int colorCount = colorSlot is not null
&& TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender)
? ColorCount(_currentPart, swatchGender)
: 0;
// AP-216 (Campaign CC gate round 1 Batch C PARTIAL -> Batch G,
// R2-5, FULL): retail's DoColorSpots @0x0047d850 blits ACTUAL-color
// art for each valid swatch and BLANK art for any swatch beyond the
// current part's real color count. The "beyond count" half shipped
// at Batch C (hiding a swatch the part's color list doesn't have);
// this batch adds the "actual color" half via
// ChargenSwatchColorResolver (see this page's own class doc).
//
// displayCount diverges from the interactive colorSlot/colorCount
// pairing for exactly one family: Nose/Mouth/Skin (colorSlot ==
// null, per ColorSlotFor's own doc) still get ONE representative
// swatch in retail — SetSelection's Nose/Mouth/Skin cases each hard-
// code var_1e0 = 1 (@0x0047e456/0x0047e4b7/0x0047e510) even though
// no ListenToElementMessage case ever makes that swatch clickable
// (SetColor's switch has no case for those three parts either).
bool swatchGenderResolved = TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender);
int colorCount = colorSlot is not null && swatchGenderResolved
? ColorCount(_currentPart, swatchGender!)
: 0;
int displayCount = colorSlot is not null ? colorCount : 1;
ChargenSwatchRgb?[] swatchColors = swatchGenderResolved
? ComputeSwatchColors(swatchGender!, snapshot.Appearance)
: new ChargenSwatchRgb?[SwatchIds.Length];
for (int i = 0; i < _swatches.Length; i++)
{
if (_swatches[i] is { } swatch)
swatch.Visible = colorSlot is not null && i < colorCount;
if (_swatches[i] is not { } swatch)
continue;
bool visible = i < displayCount;
swatch.Visible = visible;
if (_swatchColorTiles[i] is { } tile)
{
ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null;
tile.Color = rgb is { } c ? ToTintColor(c) : null;
tile.Visible = rgb is not null;
}
}
// AP-217 (PARTIAL): gmCGAppearancePage::DoGradDisk @0x0047da90
// blits the blank "grad plug" for Eyes (DoGradDisk(this, 1),
// called from SetSelection @0x0047e85d) and a gradient graphic
// TINTED with the current part's color otherwise — the tinted
// repaint needs the same palette-to-RGB pipeline AP-216's open
// half needs, so it stays open too. This ships the evidenced
// Eyes-blank half only.
// AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL):
// gmCGAppearancePage::DoGradDisk @0x0047da90 blits the blank "grad
// plug" for Eyes (DoGradDisk(this, 1), called from SetSelection
// @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY
// SELECTED swatch's own color otherwise (SetColor @0x0047dd50's
// tail, DoGradDisk(this, 0) after m_iCurColor is already updated —
// @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0
// (SetSelection hard-codes eyeColor = 0 for those three cases,
// matching displayCount's own reasoning above).
if (_gradCircle is not null)
_gradCircle.Visible = _currentPart != Part.Eyes;
{
bool isEyes = _currentPart == Part.Eyes;
_gradCircle.Visible = !isEyes;
if (_gradCircleTile is { } gradTile)
{
int gradIndex = isEyes
? -1
: colorSlot is null
? 0
: (int)ColorCurrent(_currentPart, snapshot.Appearance);
ChargenSwatchRgb? gradColor =
gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null;
gradTile.Color = gradColor is { } gc ? ToTintColor(gc) : null;
gradTile.Visible = !isEyes && gradColor is not null;
}
}
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
if (_shadeScroll is null)
@ -727,6 +829,152 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
}
}
// ── Real swatch/gradient colors (R2-5) ──────────────────────────────
private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length];
/// <summary>
/// Computes one representative <see cref="ChargenSwatchRgb"/> per
/// swatch slot (0..8, matching <see cref="SwatchIds"/>'s own order) for
/// <see cref="_currentPart"/>, or an all-null array wherever the
/// palette-resolution seams (<see cref="PalSetSource"/>/
/// <see cref="ClothingTableSource"/>/<see cref="PaletteColorSource"/>)
/// aren't wired yet — see this page's own class doc +
/// <see cref="AcDream.Core.CharGen.ChargenSwatchColorResolver"/>'s doc
/// for the retail mechanism each branch below ports.
/// </summary>
private ChargenSwatchRgb?[] ComputeSwatchColors(
ChargenGenderOptions gender, RuntimeCharacterCreationAppearance appearance)
{
if (PalSetSource is not { } palSets || PaletteColorSource is not { } colors)
return EmptySwatchColors;
var result = new ChargenSwatchRgb?[SwatchIds.Length];
switch (_currentPart)
{
case Part.Hair:
FillPalSetFamily(result, gender.HairColors, palSets, colors, ChargenSwatchColorResolver.HairSampleIndex);
break;
case Part.Eyes:
FillDirectFamily(result, gender.EyeColors, colors, ChargenSwatchColorResolver.EyeSampleIndex);
break;
case Part.Nose:
case Part.Mouth:
case Part.Skin:
// Retail: ONE representative swatch sourced from the
// single skin PalSet (SetSelection's Nose/Mouth/Skin cases,
// @0x0047e488/0x0047e4e9/0x0047e542 — all three set
// __return = 0xb0 against the same skinPalSetID DBObj get).
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, gender.SkinPalSetId,
ChargenSwatchColorResolver.SkinFamilySampleIndex, out ChargenSwatchRgb skin))
{
result[0] = skin;
}
break;
case Part.Headgear:
FillClothingFamily(result, gender, gender.Headgears, appearance.HeadgearStyle, palSets, colors);
break;
case Part.Shirt:
FillClothingFamily(result, gender, gender.Shirts, appearance.ShirtStyle, palSets, colors);
break;
case Part.Trousers:
FillClothingFamily(result, gender, gender.Pants, appearance.TrousersStyle, palSets, colors);
break;
case Part.Footwear:
FillClothingFamily(result, gender, gender.Footwear, appearance.FootwearStyle, palSets, colors);
break;
}
return result;
}
/// <summary>Hair's shape: one PalSet id per swatch index, straight off
/// <see cref="ChargenGenderOptions.HairColors"/> (already the exact
/// list <see cref="AcDream.Core.CharGen.ChargenAppearanceFactory"/>
/// indexes for the SAME selection when composing the 3D preview).</summary>
private static void FillPalSetFamily(
ChargenSwatchRgb?[] result,
IReadOnlyList<uint> palSetIds,
IChargenPalSetSource palSets,
IChargenPaletteColorSource colors,
int sampleIndex)
{
int count = Math.Min(result.Length, palSetIds.Count);
for (int i = 0; i < count; i++)
{
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, palSetIds[i], sampleIndex, out ChargenSwatchRgb c))
{
result[i] = c;
}
}
}
/// <summary>Eyes' shape: one Palette id per swatch index DIRECTLY off
/// <see cref="ChargenGenderOptions.EyeColors"/> — no PalSet
/// indirection, no averaging (see <see cref="ChargenPalSet"/>'s own
/// doc for why Eyes is the one exception).</summary>
private static void FillDirectFamily(
ChargenSwatchRgb?[] result,
IReadOnlyList<uint> paletteIds,
IChargenPaletteColorSource colors,
int sampleIndex)
{
int count = Math.Min(result.Length, paletteIds.Count);
for (int i = 0; i < count; i++)
{
if (ChargenSwatchColorResolver.TryGetDirectColor(colors, paletteIds[i], sampleIndex, out ChargenSwatchRgb c))
result[i] = c;
}
}
/// <summary>
/// Headgear/Shirt/Trousers/Footwear's shape: every swatch index shares
/// the SAME <see cref="ChargenGenderOptions.ClothingColors"/> template-id
/// list (register AP-208), resolved against the CURRENTLY EQUIPPED
/// garment's own ClothingTable — see
/// <see cref="AcDream.Core.CharGen.ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId"/>'s
/// own doc for why a direct by-id lookup reproduces retail's
/// <c>StoreColorInformation</c> result without needing its own array-
/// building order.
/// </summary>
private void FillClothingFamily(
ChargenSwatchRgb?[] result,
ChargenGenderOptions gender,
IReadOnlyList<ChargenGearOption> gearOptions,
uint styleIndex,
IChargenPalSetSource palSets,
IChargenPaletteColorSource colors)
{
if (ClothingTableSource is not { } clothingTables)
return;
// Retail: an Unset ("no garment") style leaves numHeadgearColors
// (etc) at its CharGenState::SetHeadgearStyle @0x005c5350 reset
// value of 0 — no garment equipped means no dye choices to show.
if (styleIndex == Unset || styleIndex >= (uint)gearOptions.Count)
return;
uint clothingTableId = gearOptions[(int)styleIndex].ClothingTableId;
IReadOnlyList<uint> clothingColors = gender.ClothingColors;
int count = Math.Min(result.Length, clothingColors.Count);
for (int i = 0; i < count; i++)
{
if (!ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, clothingTableId, clothingColors[i], out uint palSetId))
{
continue;
}
if (ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb c))
{
result[i] = c;
}
}
}
private static Vector4 ToTintColor(ChargenSwatchRgb rgb) =>
new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
// ── Spin captions ────────────────────────────────────────────────
/// <summary>
@ -961,5 +1209,11 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
// PreviewControl is owned by the composition root (disposed with
// the leased ChargenPreviewRenderer) — just drop the reference.
PreviewControl = null;
// R2-5: same ownership shape as PreviewControl above — these are
// borrowed references into a DAT-backed catalog the composition
// root owns, not this page's own resources.
PalSetSource = null;
ClothingTableSource = null;
PaletteColorSource = null;
}
}

View file

@ -0,0 +1,69 @@
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): paints
/// one flat-fill patch of a computed <see cref="AcDream.Core.CharGen.ChargenSwatchRgb"/>
/// on top of whatever element it is attached to as a child — the
/// Appearance page's real-color rendering primitive for the nine color
/// swatches and the gradient disc.
///
/// <para>
/// <b>Why a flat fill, not a recolored sprite (documented approximation):</b>
/// retail's own mechanism (<c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>
/// / <c>DoGradDisk @0x0047da90</c>) blits an authored "spot"/gradient
/// graphic and RECOLORS it in place
/// (<c>SurfaceWindow::ReplaceColor</c> / <c>BlitAndColor(...,
/// Blit_Multiply, color)</c>) — a genuine multiplicative texture tint. The
/// retained-UI sprite pipeline this codebase already has
/// (<see cref="UiRenderContext.DrawSprite"/>) DOES carry a per-draw
/// <c>Vector4 tint</c> parameter that could reproduce that exact multiply
/// blend, but neither <see cref="UiButton"/> (the nine swatches' own type,
/// sealed) nor <see cref="UiDatElement"/> (the gradient disc's own type)
/// exposes a per-instance tint hook on their EXISTING sprite draw calls —
/// adding one is a small, precisely-scoped, additive change to those two
/// shared widget files, outside this batch's file contract (reported as a
/// STOPPED item; see the batch's own commit message / handoff notes for the
/// exact diff). Rather than leave the swatches/wheel colorless pending that
/// follow-up, this class achieves the same OBSERVABLE result — "this
/// swatch/wheel visibly reflects the real computed color" — the cheapest
/// way the CURRENT public primitives allow: <see cref="UiRenderContext.DrawFill"/>
/// is a plain solid-color quad, so the tile reads as a flat color patch
/// rather than a recolored dot/gradient graphic. It is added as an extra
/// CHILD of the swatch/disc it decorates (never replacing or subclassing
/// either sealed/shared type), so it draws strictly ON TOP
/// (<see cref="UiElement.DrawSelfAndChildren"/>: children paint after their
/// parent's own <c>OnDraw</c>) without disturbing the underlying element's
/// own state machine, media, or click handling at all.
/// </para>
///
/// <para>
/// <see cref="UiElement.ClickThrough"/> defaults to <c>false</c> on the
/// base class, so this MUST be set true by the constructor here (not left
/// to a caller to remember) — <see cref="UiElement.HitTest"/> walks
/// children BEFORE testing the parent, and an opaque, click-absorbing tile
/// sitting on top of a swatch button would silently eat every click meant
/// for it.
/// </para>
/// </summary>
internal sealed class ChargenSwatchColorTile : UiElement
{
public ChargenSwatchColorTile()
{
ClickThrough = true;
Visible = false;
}
/// <summary>The color to paint, or null to draw nothing this frame
/// (<see cref="Visible"/> is the authoritative on/off switch — callers
/// should set both together, matching every other swatch-visibility
/// site in <c>CharacterCreationAppearancePage</c>).</summary>
public Vector4? Color { get; set; }
protected override void OnDraw(UiRenderContext ctx)
{
if (Color is { } c && Width > 0f && Height > 0f)
ctx.DrawFill(0f, 0f, Width, Height, c);
}
}

View file

@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Collections.Frozen;
using AcDream.Core.CharGen;
using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable;
using DatPalette = DatReaderWriter.DBObjs.Palette;
using DatPalSet = DatReaderWriter.DBObjs.PalSet;
using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect;
using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette;
@ -32,12 +33,24 @@ namespace AcDream.Content.CharGen;
/// protect the CACHE from concurrent mutation — they do nothing for the
/// underlying <c>DatCollection</c> read the cache miss triggers.
/// </para>
///
/// <para>
/// <b><see cref="IChargenPaletteColorSource"/> (Campaign CC gate round 1
/// Batch G, R2-5):</b> the real color-wheel/swatch mechanism
/// (<c>ChargenSwatchColorResolver</c>) needs one more DAT read this class
/// didn't previously do — a raw Palette dat object's (0x04......) own color
/// table, retail's <c>Palette::get_color32</c> equivalent. Same lazy-cache
/// shape as <see cref="TryGetPalSet"/>/<see cref="TryGetClothingTable"/>,
/// same DAT-lock obligation on every call site.
/// </para>
/// </summary>
public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClothingTableSource
public sealed class ChargenAppearanceCatalog :
IChargenPalSetSource, IChargenClothingTableSource, IChargenPaletteColorSource
{
private readonly IDatReaderWriter _dats;
private readonly ConcurrentDictionary<uint, ChargenPalSet?> _palSets = new();
private readonly ConcurrentDictionary<uint, ChargenClothingTable?> _clothingTables = new();
private readonly ConcurrentDictionary<uint, DatPalette?> _palettes = new();
public ChargenAppearanceCatalog(IDatReaderWriter dats)
{
@ -50,6 +63,28 @@ public sealed class ChargenAppearanceCatalog : IChargenPalSetSource, IChargenClo
public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) =>
_clothingTables.GetOrAdd(clothingTableId, LoadClothingTable);
/// <summary>
/// Retail's <c>ClientCharGenState::GetColorFromPal @0x00563990</c>: load
/// the Palette dat object and read its color table at a fixed index —
/// direct <c>ARGB[index]</c>, no averaging, no shade indirection. Unlike
/// retail's own unchecked array read, this bounds-checks
/// <paramref name="index"/> against the loaded palette's actual color
/// count and returns false rather than reading out of range (see
/// <see cref="IChargenPaletteColorSource.TryGetColor"/>'s own doc for
/// why that divergence is deliberate).
/// </summary>
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color)
{
color = default;
DatPalette? palette = _palettes.GetOrAdd(paletteId, id => _dats.Get<DatPalette>(id));
if (palette is null || index < 0 || index >= palette.Colors.Count)
return false;
DatReaderWriter.Types.ColorARGB c = palette.Colors[index];
color = new ChargenSwatchRgb(c.Red, c.Green, c.Blue);
return true;
}
private ChargenPalSet? LoadPalSet(uint id)
{
DatPalSet? palSet = _dats.Get<DatPalSet>(id);

View file

@ -0,0 +1,46 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// A resolved representative swatch color — the RGB byte triple retail's
/// <c>Palette::get_color32 @0x0053e050</c> (a direct <c>ARGB[index]</c>
/// read, no bounds check on the real client) yields for one fixed sample
/// index into a Palette dat object's (0x04......) color table. Alpha is
/// deliberately omitted: retail's swatch/gradient recolor path
/// (<c>SurfaceWindow::ReplaceColor</c> /
/// <c>SurfaceWindow::BlitAndColor(..., Blit_Multiply, ...)</c>) only ever
/// reads R/G/B out of the sampled color — <c>gmCGAppearancePage</c>'s
/// <c>m_tColorWheel</c> entries carry <c>iRed</c>/<c>iGreen</c>/<c>iBlue</c>
/// fields and no <c>iAlpha</c> at all (<c>DoColorSpots @0x0047d850</c>,
/// <c>DoGradDisk @0x0047da90</c>).
/// </summary>
public readonly record struct ChargenSwatchRgb(byte R, byte G, byte B);
/// <summary>
/// Resolves one Palette dat object (0x04......) to a representative color
/// at a fixed sample index — retail's
/// <c>ClientCharGenState::GetColorFromPal @0x00563990</c>
/// (<c>DBObj::Get(QualifiedDataID(id, PALETTE_TYPE=0xa))</c> then
/// <c>Palette::get_color32(index)</c>, i.e. a direct, unchecked
/// <c>ARGB[index]</c> read). The production implementation
/// (<c>AcDream.Content.CharGen.ChargenAppearanceCatalog</c>) reads and
/// caches the real dat object, matching <see cref="IChargenPalSetSource"/>'s
/// established Core/Content split (interface in Core, Chorizite-backed
/// implementation in Content); unit tests supply a hand-built fake so this
/// interface's only consumer, <see cref="ChargenSwatchColorResolver"/>,
/// stays free of any Chorizite dependency.
/// </summary>
public interface IChargenPaletteColorSource
{
/// <summary>
/// Returns false when the Palette dat object itself doesn't resolve, OR
/// when <paramref name="index"/> falls outside its color table —
/// retail's own <c>Palette::get_color32</c> has NO bounds check (a
/// genuinely unchecked <c>ARGB[index]</c> read), so this is a
/// deliberate defensive divergence: acdream cannot reproduce retail's
/// undefined-behavior read as safe managed code, and treats an
/// out-of-range sample the same as a missing palette (no representative
/// color, caller skips the contribution) rather than throwing or
/// fabricating a value.
/// </summary>
bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color);
}

View file

@ -0,0 +1,197 @@
namespace AcDream.Core.CharGen;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217's
/// remaining halves): ports the color-computation half of
/// <c>gmCGAppearancePage::SetSelection @0x0047e260</c> — the per-swatch
/// representative RGB retail stores into <c>m_tColorWheel[i].iRed/iGreen/
/// iBlue</c> before <c>DoColorSpots @0x0047d850</c> paints it and
/// <c>DoGradDisk @0x0047da90</c> tints the gradient disc with the CURRENTLY
/// selected swatch's own entry.
///
/// <para>
/// <b>Two distinct color-source shapes, both decomp-traced:</b>
/// </para>
/// <list type="number">
/// <item>
/// <b>PalSet-averaged (Hair / Nose+Mouth+Skin / Headgear / Shirt /
/// Trousers / Footwear).</b> Retail resolves ONE PalSet id per swatch
/// index, then — for EVERY Palette id inside that PalSet (its <c>num_pals</c>
/// sub-palettes/shades) — samples a FIXED index via
/// <c>GetColorFromPal</c> and averages the R/G/B channels
/// (<c>@0x0047e759-0x0047e80f</c>, the shared loop every non-Eyes case
/// jumps into at <c>label_47e74b</c>). The averaging is intentional: the
/// swatch shows one representative hue for a color CHOICE that actually
/// spans several shade variants, not any single shade.
/// </item>
/// <item>
/// <b>Direct (Eyes only).</b> Retail uses the raw entry from
/// <c>ChargenGenderOptions.EyeColors</c> directly as a Palette id — no
/// PalSet indirection, no averaging, one <c>GetColorFromPal</c> call per
/// swatch (<c>@0x0047e3bf-0x0047e40f</c>), matching
/// <see cref="ChargenPalSet"/>'s own doc for why Eyes is the one
/// exception to the PalSet convention everywhere else in this campaign.
/// </item>
/// </list>
///
/// <para>
/// <b>Clothing's PalSet id source (Headgear/Shirt/Trousers/Footwear)</b> is
/// itself retail's own second-order lookup: <c>CharGenState::SetHeadgearStyle
/// @0x005c5350</c> (and its Shirt/Trousers/Footwear siblings,
/// <c>@0x005c5470/0x005c5590/0x005c56b0</c>) call
/// <c>StoreColorInformation @0x005c44d0</c> against the NEWLY SELECTED
/// garment's own ClothingTable (<c>DBObj::Get(clothingTableId, 0x19)</c>)
/// every time the style changes, walking that table's own
/// <c>CloPaletteTemplate</c> hash table and recording — for every template
/// id that ALSO appears in the gender's shared
/// <see cref="ChargenGenderOptions.ClothingColors"/> list — that template's
/// FIRST sub-palette choice's PalSet id
/// (<c>headgearPalSetIDs[]</c>/<c>shirtPalSetIDs[]</c>/etc, offset <c>+0x10</c>
/// off the copied <c>CloPaletteTemplate</c>, a decompiler-elided field read
/// cross-checked against this codebase's own
/// <c>ChargenClothingSubPaletteChoice.PalSetId</c> — the first
/// <c>Choices</c> entry of the SAME projected shape).
/// <see cref="TryGetClothingSwatchPalSetId"/> reproduces the OBSERVABLE
/// result (which PalSet a given <see cref="ChargenGenderOptions.ClothingColors"/>
/// index represents for the currently equipped garment) via a direct
/// dictionary lookup by template id rather than replicating retail's own
/// array-building traversal — a hash-table walk's OWN internal bucket order
/// is an implementation detail of retail's cache, not part of the
/// observable behavior, and a by-id lookup is provably order-independent.
/// This keeps the swatch index space IDENTICAL to what
/// <c>ChargenAppearanceFactory.ComposeClothingSlot</c> already treats as
/// canonical (<c>gender.ClothingColors[(int)colorIndex]</c>, the SAME
/// index space <c>RuntimeCharacterCreationAppearance</c> persists and the
/// 3D preview already renders correctly from, proven across this
/// campaign's own installed-DAT and live two-client gates) — deliberately
/// NOT re-deriving a second, potentially-divergent index space from
/// <c>StoreColorInformation</c>'s own cache-building order.
/// </para>
/// </summary>
public static class ChargenSwatchColorResolver
{
/// <summary>Hair's fixed sample index (<c>gmCGAppearancePage::SetSelection</c>
/// case ECG_PARTS_HAIR, <c>__return = 0xd0</c> @0x0047e388).</summary>
public const int HairSampleIndex = 0xd0;
/// <summary>Nose/Mouth/Skin's shared fixed sample index — all three route
/// to the SAME single-entry PalSet id (<c>ChargenGenderOptions.SkinPalSetId</c>)
/// with <c>__return = 0xb0</c> (Nose @0x0047e488, Mouth @0x0047e4e9, Skin
/// @0x0047e542).</summary>
public const int SkinFamilySampleIndex = 0xb0;
/// <summary>Eyes' fixed sample index, used DIRECTLY against
/// <c>ChargenGenderOptions.EyeColors[i]</c> with no PalSet indirection
/// (case ECG_PARTS_EYES, <c>GetColorFromPal(..., 0x103)</c>
/// @0x0047e3e2).</summary>
public const int EyeSampleIndex = 0x103;
/// <summary>The shared clothing sample index — Headgear/Shirt/Trousers/
/// Footwear all set <c>__return = 0x520</c> before falling into the
/// shared averaging loop (@0x0047e5be/0x0047e62b/0x0047e6ab/0x0047e733).</summary>
public const int ClothingSampleIndex = 0x520;
/// <summary>
/// PalSet-averaged representative color (Hair / Nose+Mouth+Skin /
/// Headgear / Shirt / Trousers / Footwear) — retail's shared
/// <c>label_47e74b</c> loop: resolve the PalSet, sample every one of its
/// Palette ids at <paramref name="sampleIndex"/>, average the R/G/B
/// channels. A PalSet that resolves but carries zero Palette ids
/// reproduces retail's own explicit zero-init with no averaging
/// division (<c>@0x0047e790</c> zeroes <c>iRed/iGreen/iBlue</c>
/// unconditionally before the <c>num_pals &gt; 0</c> guard) — returns
/// true with a BLACK color, not false, matching retail's actual output
/// for that shape. Returns false only when the PalSet id itself doesn't
/// resolve at all (retail's outer <c>if (__return_8 != 0)</c> miss,
/// which leaves that swatch's <c>m_tColorWheel</c> entry untouched from
/// whatever it held before — the closest acdream equivalent is "no
/// color to paint this swatch with").
/// </summary>
public static bool TryGetPalSetAverageColor(
IChargenPalSetSource palSets,
IChargenPaletteColorSource colors,
uint palSetId,
int sampleIndex,
out ChargenSwatchRgb color)
{
ArgumentNullException.ThrowIfNull(palSets);
ArgumentNullException.ThrowIfNull(colors);
color = default;
ChargenPalSet? palSet = palSets.TryGetPalSet(palSetId);
if (palSet is null)
return false;
if (palSet.PaletteIds.Count == 0)
return true; // retail: explicit zero-init, no division — black.
int sumR = 0, sumG = 0, sumB = 0;
foreach (uint paletteId in palSet.PaletteIds)
{
// A per-entry miss contributes (0,0,0) to the running sum —
// retail's own loop (@0x0047e7a5-0x0047e7dc) accumulates
// unconditionally and always divides by the FULL num_pals
// afterward; GetColorFromPal's own miss path
// (@0x005639b5) returns 0 rather than skipping the entry.
if (colors.TryGetColor(paletteId, sampleIndex, out ChargenSwatchRgb c))
{
sumR += c.R;
sumG += c.G;
sumB += c.B;
}
}
int count = palSet.PaletteIds.Count;
color = new ChargenSwatchRgb((byte)(sumR / count), (byte)(sumG / count), (byte)(sumB / count));
return true;
}
/// <summary>
/// Direct representative color (Eyes only) — one
/// <see cref="IChargenPaletteColorSource.TryGetColor"/> call against
/// <paramref name="paletteId"/> with no PalSet indirection and no
/// averaging, matching retail's ECG_PARTS_EYES case exactly.
/// </summary>
public static bool TryGetDirectColor(
IChargenPaletteColorSource colors,
uint paletteId,
int sampleIndex,
out ChargenSwatchRgb color)
{
ArgumentNullException.ThrowIfNull(colors);
return colors.TryGetColor(paletteId, sampleIndex, out color);
}
/// <summary>
/// Resolves the PalSet id a clothing swatch index represents for the
/// CURRENTLY EQUIPPED garment — see this class's own doc for why a
/// direct by-id lookup reproduces retail's observable
/// <c>StoreColorInformation</c> result without replicating its own
/// cache-building traversal order. Returns false when the garment's
/// ClothingTable doesn't resolve, has no palette template for
/// <paramref name="paletteTemplateId"/>, or that template carries no
/// sub-palette choices at all (an authored garment with a dye slot but
/// literally zero dye options) — every case retail's own miss paths
/// treat as "this swatch has no color."
/// </summary>
public static bool TryGetClothingSwatchPalSetId(
IChargenClothingTableSource clothingTables,
uint clothingTableId,
uint paletteTemplateId,
out uint palSetId)
{
ArgumentNullException.ThrowIfNull(clothingTables);
palSetId = 0;
ChargenClothingTable? table = clothingTables.TryGetClothingTable(clothingTableId);
if (table is null)
return false;
if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template))
return false;
if (template.Choices.Count == 0)
return false;
palSetId = template.Choices[0].PalSetId;
return true;
}
}

View file

@ -0,0 +1,447 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217): fixture
/// tests for <see cref="CharacterCreationAppearancePage"/>'s real swatch/
/// gradient-disc color computation — a hand-built layout + fake DAT sources
/// (no installed dat), so recompute triggers can be asserted deterministically.
/// Real installed-DAT color values are pinned separately in
/// <c>AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests</c>.
/// </summary>
public sealed class CharacterCreationAppearancePageSwatchColorTests
{
private const uint HeritageId = 1u;
private const int GenderKey = 1;
private const uint HairPalSetIdA = 0x0F00_0001u;
private const uint HairPalSetIdB = 0x0F00_0002u;
private const uint EyePaletteIdA = 0x0400_0010u;
private const uint EyePaletteIdB = 0x0400_0011u;
private const uint HeadgearClothingTableId = 0x1900_0001u;
private const uint HeadgearDyePalSetId = 0x0F00_0010u;
private const uint SkinPalSetId = 0x0F00_0099u;
private static readonly ChargenSwatchRgb HairColorA = new(10, 20, 30);
private static readonly ChargenSwatchRgb HairColorB = new(40, 50, 60);
private static readonly ChargenSwatchRgb EyeColorA = new(70, 80, 90);
private static readonly ChargenSwatchRgb EyeColorB = new(100, 110, 120);
private static readonly ChargenSwatchRgb HeadgearColorA = new(130, 140, 150);
private static readonly ChargenSwatchRgb SkinColor = new(160, 170, 180);
private sealed class FakePalSetSource : IChargenPalSetSource
{
private readonly Dictionary<uint, ChargenPalSet> _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<uint, ChargenClothingTable> _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 sealed class FakeColorSource : IChargenPaletteColorSource
{
private readonly Dictionary<uint, ChargenSwatchRgb> _colors = new();
public void Add(uint paletteId, ChargenSwatchRgb color) => _colors[paletteId] = color;
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) =>
_colors.TryGetValue(paletteId, out color);
}
private static ChargenGenderOptions MakeGender() => new(
GenderKey: GenderKey,
Name: "Male",
Scale: 100u,
SetupId: 0x0200_0001u,
SoundTableId: 0u,
IconId: 0u,
BasePaletteId: 0u,
SkinPalSetId: SkinPalSetId,
PhysicsTableId: 0u,
MotionTableId: 0u,
CombatTableId: 0u,
BaseObjDesc: ChargenObjDesc.Empty,
HairColors: [HairPalSetIdA, HairPalSetIdB],
HairStyles: [new ChargenHairStyle(0u, false, 0u, ChargenObjDesc.Empty)],
EyeColors: [EyePaletteIdA, EyePaletteIdB],
EyeStrips: [new ChargenEyeStrip(0u, 0u, ChargenObjDesc.Empty, ChargenObjDesc.Empty)],
NoseStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
MouthStrips: [new ChargenFaceStrip(0u, ChargenObjDesc.Empty)],
Headgears: [new ChargenGearOption("Cap", HeadgearClothingTableId, 0u)],
Shirts: [],
Pants: [],
Footwear: [],
ClothingColors: [9u]);
private static ChargenOptions MakeOptions(uint heritageId, ChargenGenderOptions gender)
{
var heritage = new ChargenHeritageOptions(
heritageId, "Test", 0u, 0x0200_0001u, 0u,
180u, 100u, [0], [],
new Dictionary<uint, ChargenSkillCost>(), [],
new Dictionary<int, ChargenGenderOptions> { [GenderKey] = gender });
return new ChargenOptions(
[],
new Dictionary<uint, ChargenHeritageOptions> { [heritageId] = heritage },
new Dictionary<uint, ChargenSkillCost>());
}
private static (FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors) MakeSources()
{
var pal = new FakePalSetSource();
pal.Add(HairPalSetIdA, 0x0400_0001u);
pal.Add(HairPalSetIdB, 0x0400_0002u);
pal.Add(HeadgearDyePalSetId, 0x0400_0003u);
pal.Add(SkinPalSetId, 0x0400_0004u);
var colors = new FakeColorSource();
colors.Add(0x0400_0001u, HairColorA);
colors.Add(0x0400_0002u, HairColorB);
colors.Add(EyePaletteIdA, EyeColorA);
colors.Add(EyePaletteIdB, EyeColorB);
colors.Add(0x0400_0003u, HeadgearColorA);
colors.Add(0x0400_0004u, SkinColor);
var choice = new ChargenClothingSubPaletteChoice(HeadgearDyePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]);
var templates = new Dictionary<uint, ChargenClothingPaletteTemplate>
{
[9u] = new ChargenClothingPaletteTemplate([choice]),
};
var clothing = new FakeClothingTableSource();
clothing.Add(HeadgearClothingTableId, new ChargenClothingTable(
new Dictionary<uint, ChargenClothingBaseEffect>(), templates));
return (pal, clothing, colors);
}
private sealed class FakeView(ChargenOptions options, uint heritageId) : IRuntimeCharacterCreationView
{
public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = new(
new RuntimeGenerationToken(1u),
IsActive: true,
Revision: 1,
HeritageId: heritageId,
GenderKey: (uint)GenderKey,
Appearance: RuntimeCharacterCreationAppearance.Default,
Template: RuntimeCharacterCreationSnapshot.TemplateUnset,
Attributes: default,
AttributeLockMask: 0u,
TotalAttributeCredits: 0u,
RemainingAttributeCredits: 0,
TotalSkillCredits: 0u,
RemainingSkillCredits: 0,
Name: string.Empty,
StartArea: -1,
Slot: 0u,
VerificationPending: false,
LastLocalRefusal: default,
LastRejection: null,
LastCreated: null);
public ChargenOptions Options { get; } = options;
public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => ChargenSkillAdvancementClass.Inactive;
public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => NullSubscription.Instance;
private sealed class NullSubscription : IDisposable
{
public static readonly NullSubscription Instance = new();
public void Dispose() { }
}
}
/// <summary>Minimal appearance-page fixture — just what
/// <see cref="CharacterCreationAppearancePage"/>'s constructor resolves
/// (spins for Hair/Eyes/Headgear used to drive <c>_currentPart</c> via
/// their own select-zone click, matching every other test in this
/// campaign's <c>CharacterCreationUiControllerTests</c> harness; the
/// nine swatches + the gradient disc, this test's own subject).</summary>
private static UiElement BuildPageRoot()
{
var page = new ElementInfo
{
Id = 0x100003D4u,
Type = 3u,
Width = 800f,
Height = 500f,
};
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HairSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.EyesSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.SkinSpinId));
page.Children.Add(SpinInfo(CharacterCreationAppearancePage.HeadgearSpinId));
foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds)
page.Children.Add(ButtonInfo(swatchId));
foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds)
page.Children.Add(ContainerInfo(overlayId));
page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId));
return LayoutImporter.Build(page, _ => (0u, 0, 0), null).Root;
}
private static ElementInfo SpinInfo(uint id)
{
var spin = new ElementInfo { Id = id, Type = 1u, Width = 200f, Height = 24f };
spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f });
spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f });
return spin;
}
private static ElementInfo ButtonInfo(uint id) => new() { Id = id, Type = 1u, Width = 20f, Height = 20f };
private static ElementInfo ContainerInfo(uint id) => new() { Id = id, Type = 3u, Width = 64f, Height = 64f };
private static (CharacterCreationAppearancePage Page, FakeView View, UiElement Root) BuildPage(
FakePalSetSource pal, FakeClothingTableSource clothing, FakeColorSource colors)
{
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings(
() => view,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { },
SetAppearanceIndex: (_, _) => default);
UiElement pageRoot = BuildPageRoot();
var page = new CharacterCreationAppearancePage(pageRoot, bindings)
{
PalSetSource = pal,
ClothingTableSource = clothing,
PaletteColorSource = colors,
};
return (page, view, pageRoot);
}
private static UiButton Swatch(UiElement pageRoot, int index) =>
Assert.IsType<UiButton>(UiElement.FindDescendant(
pageRoot, CharacterCreationAppearancePage.SwatchIds[index]));
private static ChargenSwatchColorTile SwatchTile(UiElement pageRoot, int index) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(Swatch(pageRoot, index).Children));
private static ChargenSwatchColorTile GradTile(UiElement pageRoot) =>
Assert.IsType<ChargenSwatchColorTile>(Assert.Single(
UiElement.FindDescendant(pageRoot, CharacterCreationAppearancePage.GradCircleId)!.Children));
private static Vector4 ToVector4(ChargenSwatchRgb rgb) => new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f);
[Fact]
public void HairPart_PaintsBothSwatchesWithTheirOwnDistinctColors()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
Assert.True(SwatchTile(root, 0).Visible);
Assert.Equal(ToVector4(HairColorB), SwatchTile(root, 1).Color);
Assert.True(SwatchTile(root, 1).Visible);
// Only two hair colors exist — swatch 2 must be blank.
Assert.Null(SwatchTile(root, 2).Color);
Assert.False(SwatchTile(root, 2).Visible);
}
/// <summary>Part change (Hair -&gt; Eyes via the spin's own select-zone
/// click) must recompute the whole swatch set from the NEW part's own
/// color source.</summary>
[Fact]
public void PartChange_RecomputesTheSwatchSet()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
UiButton eyesSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10); // select zone — switches _currentPart, no index change.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
Assert.Equal(ToVector4(EyeColorB), SwatchTile(root, 1).Color);
}
/// <summary>Color change (clicking a different swatch) must retint the
/// gradient disc to the NEWLY selected swatch's own color.</summary>
[Fact]
public void ColorChange_RetintsTheGradientDiscToTheNewlySelectedSwatch()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
// No color selected yet (Unset) — no tint.
Assert.Null(GradTile(root).Color);
Assert.False(GradTile(root).Visible);
Swatch(root, 0).OnClick!();
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HairColor = 0u },
};
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), GradTile(root).Color);
Assert.True(GradTile(root).Visible);
Swatch(root, 1).OnClick!();
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HairColor = 1u },
};
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorB), GradTile(root).Color);
}
/// <summary>AP-217: Eyes always blanks the gradient disc's tile — no
/// tint is ever shown for Eyes, regardless of the selected eye color.</summary>
[Fact]
public void EyesPart_GradientDiscTileStaysBlank()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { EyeColor = 0u },
};
page.Refresh(view, view.Snapshot);
UiButton eyesSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.EyesSpinId));
eyesSpin.OnClickAt!(180, 10);
Assert.False(GradTile(root).Visible);
Assert.Null(GradTile(root).Color);
// The swatches themselves still show real eye colors — only the
// disc blanks.
Assert.Equal(ToVector4(EyeColorA), SwatchTile(root, 0).Color);
}
/// <summary>Nose/Mouth/Skin (colorSlot == null): retail still shows
/// exactly ONE representative swatch, sourced from the shared skin
/// PalSet, and the gradient disc tints from that SAME swatch — see
/// <c>ComputeSwatchColors</c>'s own doc.</summary>
[Fact]
public void SkinPart_ShowsExactlyOneRepresentativeSwatchAndTintsTheDiscFromIt()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
UiButton skinSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.SkinSpinId));
skinSpin.OnClickAt!(10, 10); // skin has no arrow zones — every click selects it.
Assert.Equal(ToVector4(SkinColor), SwatchTile(root, 0).Color);
Assert.True(SwatchTile(root, 0).Visible);
Assert.Null(SwatchTile(root, 1).Color);
Assert.False(SwatchTile(root, 1).Visible);
Assert.Equal(ToVector4(SkinColor), GradTile(root).Color);
Assert.True(GradTile(root).Visible);
}
/// <summary>Headgear's swatch resolves through the CURRENTLY EQUIPPED
/// garment's own ClothingTable — an Unset headgear style (no garment)
/// shows no swatches at all.</summary>
[Fact]
public void HeadgearPart_ResolvesThroughTheEquippedGarment_UnsetShowsNoSwatches()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HeadgearStyle = 0u },
};
page.Refresh(view, view.Snapshot);
UiButton headgearSpin = Assert.IsType<UiButton>(
UiElement.FindDescendant(root, CharacterCreationAppearancePage.HeadgearSpinId));
headgearSpin.OnClickAt!(180, 10);
Assert.Equal(ToVector4(HeadgearColorA), SwatchTile(root, 0).Color);
// Now un-equip (Unset) and refresh again — no garment, no colors.
view.Snapshot = view.Snapshot with
{
Appearance = view.Snapshot.Appearance with { HeadgearStyle = RuntimeCharacterCreationAppearance.Unset },
};
page.Refresh(view, view.Snapshot);
Assert.Null(SwatchTile(root, 0).Color);
Assert.False(SwatchTile(root, 0).Visible);
}
/// <summary>Heritage/gender change must re-source the color computation
/// from the NEW gender's own option lists — a second, differently-
/// colored fixture proves the recompute isn't cached from the first.</summary>
[Fact]
public void HeritageChange_RecomputesFromTheNewGendersOwnColorLists()
{
var (pal, clothing, colors) = MakeSources();
(CharacterCreationAppearancePage page, FakeView view, UiElement root) = BuildPage(pal, clothing, colors);
page.Refresh(view, view.Snapshot);
Assert.Equal(ToVector4(HairColorA), SwatchTile(root, 0).Color);
// A second heritage with a DIFFERENT hair-color list.
const uint otherHairPalSetId = 0x0F00_00AAu;
var otherHairColor = new ChargenSwatchRgb(200, 201, 202);
pal.Add(otherHairPalSetId, 0x0400_00AAu);
colors.Add(0x0400_00AAu, otherHairColor);
const uint otherHeritageId = 2u;
ChargenGenderOptions otherGender = MakeGender() with { HairColors = [otherHairPalSetId] };
ChargenOptions otherOptions = MakeOptions(otherHeritageId, otherGender);
var otherView = new FakeView(otherOptions, otherHeritageId);
page.Refresh(otherView, otherView.Snapshot);
Assert.Equal(ToVector4(otherHairColor), SwatchTile(root, 0).Color);
}
/// <summary>Before <see cref="CharacterCreationAppearancePage.PalSetSource"/>/
/// <see cref="CharacterCreationAppearancePage.ClothingTableSource"/>/
/// <see cref="CharacterCreationAppearancePage.PaletteColorSource"/> are
/// wired (composition-root STOPPED item), every tile MUST stay
/// invisible — the mechanism is fully inert, not a half-broken draw.</summary>
[Fact]
public void UnwiredSources_LeaveEveryTileInvisible()
{
var view = new FakeView(MakeOptions(HeritageId, MakeGender()), HeritageId);
var bindings = new CharacterCreationRuntimeBindings(
() => view,
_ => default,
_ => default,
_ => default,
(_, _) => default,
(_, _) => default,
_ => default,
_ => default,
_ => default,
_ => default,
_ => default,
() => { },
SetAppearanceIndex: (_, _) => default);
UiElement root = BuildPageRoot();
var page = new CharacterCreationAppearancePage(root, bindings); // sources left null.
page.Refresh(view, view.Snapshot);
Assert.False(GradTile(root).Visible);
for (int i = 0; i < CharacterCreationAppearancePage.SwatchIds.Length; i++)
Assert.False(SwatchTile(root, i).Visible);
}
}

View file

@ -0,0 +1,234 @@
using AcDream.Content.CharGen;
using AcDream.Core.CharGen;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit.Abstractions;
namespace AcDream.Content.Tests.CharGen;
/// <summary>
/// Installed-DAT gate for <see cref="ChargenAppearanceCatalog"/>'s
/// <see cref="IChargenPaletteColorSource"/> implementation, PLUS
/// <see cref="ChargenSwatchColorResolver"/> exercised end-to-end against
/// real dat data — Campaign CC gate round 1 Batch G (R2-5, register
/// AP-216/AP-217). Values below are MEASURED against the installed EoR dat
/// (Aluvian male, heritage 1 / gender 1 — the same probe subject
/// <c>ChargenAppearanceCatalogInstalledDatTests</c> uses), not assumed: a
/// live probe (<c>ScratchPaletteProbe</c>, this batch's throwaway
/// investigation harness) printed every value pinned here before it was
/// written into an assertion.
///
/// <para>Env-gated skip (house pattern, matched from
/// <c>ChargenAppearanceCatalogInstalledDatTests</c>): returns green with a
/// console SKIP note when no installed dat directory is configured.</para>
/// </summary>
public sealed class ChargenAppearanceCatalogColorTests
{
private readonly ITestOutputHelper _out;
public ChargenAppearanceCatalogColorTests(ITestOutputHelper output) => _out = output;
private const uint AluvianId = 1u;
private const int MaleGenderKey = 1;
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;
}
private static (ChargenGenderOptions gender, ChargenAppearanceCatalog catalog)? LoadAluvianMale(
DatCollectionAdapter adapter)
{
ChargenOptions options = ChargenTableReader.Load(adapter);
if (!options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? heritage))
return null;
if (!heritage.GendersByKey.TryGetValue(MaleGenderKey, out ChargenGenderOptions? gender))
return null;
return (gender, new ChargenAppearanceCatalog(adapter));
}
/// <summary>
/// Direct <see cref="ChargenAppearanceCatalog.TryGetColor"/> pin — the
/// Eye family's shape (no PalSet indirection): reading the SAME palette
/// id/index pair twice returns the SAME color (cache correctness) and
/// matches the measured pixel.
/// </summary>
[Fact]
public void TryGetColor_EyePaletteAtFixedIndex_MatchesMeasuredPixel()
{
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);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.NotEmpty(gender.EyeColors);
bool ok = catalog.TryGetColor(
gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color);
// Re-reading the SAME palette (cache hit path) is byte-identical.
catalog.TryGetColor(gender.EyeColors[0], ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb again);
Assert.Equal(color, again);
}
[Fact]
public void TryGetColor_OutOfRangeIndex_ReturnsFalse()
{
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);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
bool ok = catalog.TryGetColor(gender.EyeColors[0], index: int.MaxValue, out _);
Assert.False(ok);
}
/// <summary>
/// End-to-end through <see cref="ChargenSwatchColorResolver.TryGetPalSetAverageColor"/>
/// for Hair — averaged across every one of Aluvian male's 13 hair-color
/// PalSet's own sub-palettes.
/// </summary>
[Fact]
public void HairSwatchZero_AveragedAcrossPalSet_MatchesMeasuredColor()
{
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);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.NotEmpty(gender.HairColors);
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, gender.HairColors[0], ChargenSwatchColorResolver.HairSampleIndex,
out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(101, 94, 4), color);
}
/// <summary>Distinct hair-color swatches must resolve to DISTINCT
/// colors — the whole point of the mechanism (R2-5's complaint was that
/// every swatch showed the SAME static art regardless of which color it
/// represents).</summary>
[Fact]
public void EveryHairColorSwatch_ResolvesToADistinctColor()
{
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);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.True(gender.HairColors.Count > 1, "fixture assumption: Aluvian male has >1 hair color choice");
var seen = new HashSet<ChargenSwatchRgb>();
foreach (uint palSetId in gender.HairColors)
{
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, palSetId, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
_out.WriteLine($"hair palSet=0x{palSetId:X8} rgb=({color.R},{color.G},{color.B})");
Assert.True(seen.Add(color), $"duplicate representative color {color} for palSet 0x{palSetId:X8}");
}
}
/// <summary>
/// End-to-end through the clothing family's two-step lookup
/// (<see cref="ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId"/>
/// then <see cref="ChargenSwatchColorResolver.TryGetPalSetAverageColor"/>)
/// for Aluvian male's first headgear garment.
/// </summary>
[Fact]
public void HeadgearSwatchZero_ResolvesThroughTheEquippedGarmentsClothingTable()
{
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);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
Assert.NotEmpty(gender.Headgears);
Assert.NotEmpty(gender.ClothingColors);
ChargenGearOption firstHeadgear = gender.Headgears[0];
bool palSetOk = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
catalog, firstHeadgear.ClothingTableId, gender.ClothingColors[0], out uint palSetId);
Assert.True(palSetOk);
Assert.Equal(0x0F000009u, palSetId);
bool colorOk = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb color);
Assert.True(colorOk);
Assert.Equal(new ChargenSwatchRgb(59, 59, 59), color);
}
/// <summary>The Nose/Mouth/Skin family's shared single representative
/// swatch — sourced from <see cref="ChargenGenderOptions.SkinPalSetId"/>,
/// not any per-part list.</summary>
[Fact]
public void SkinFamilySwatch_ResolvesFromTheSharedSkinPalSet()
{
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);
var loaded = LoadAluvianMale(adapter);
Assert.NotNull(loaded);
(ChargenGenderOptions gender, ChargenAppearanceCatalog catalog) = loaded!.Value;
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
catalog, catalog, gender.SkinPalSetId, ChargenSwatchColorResolver.SkinFamilySampleIndex,
out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(182, 148, 118), color); // a plausible flesh tone.
}
}

View file

@ -0,0 +1,212 @@
using AcDream.Core.CharGen;
namespace AcDream.Core.Tests.CharGen;
/// <summary>
/// Hand-built-fixture tests for <see cref="ChargenSwatchColorResolver"/> —
/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217). Real
/// installed-DAT coverage (pinned RGB values off the actual client dats)
/// lives in <c>AcDream.Content.Tests.CharGen.ChargenAppearanceCatalogColorTests</c>.
/// </summary>
public sealed class ChargenSwatchColorResolverTests
{
private sealed class FakePalSetSource : IChargenPalSetSource
{
private readonly Dictionary<uint, ChargenPalSet> _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<uint, ChargenClothingTable> _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;
}
/// <summary>Fixed-color fake: every (paletteId, index) pair the test
/// registers resolves to an EXACT color, so averaging math can be
/// checked by hand rather than against opaque installed-DAT pixels.</summary>
private sealed class FakeColorSource : IChargenPaletteColorSource
{
private readonly Dictionary<(uint paletteId, int index), ChargenSwatchRgb> _colors = new();
public void Add(uint paletteId, int index, byte r, byte g, byte b) =>
_colors[(paletteId, index)] = new ChargenSwatchRgb(r, g, b);
public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) =>
_colors.TryGetValue((paletteId, index), out color);
}
// ── TryGetPalSetAverageColor ────────────────────────────────────────
[Fact]
public void TryGetPalSetAverageColor_AveragesEveryPaletteInTheSet()
{
var palSets = new FakePalSetSource();
palSets.Add(0x0F00_0001u, 0x0400_0001u, 0x0400_0002u);
var colors = new FakeColorSource();
colors.Add(0x0400_0001u, 0xd0, r: 100, g: 0, b: 0);
colors.Add(0x0400_0002u, 0xd0, r: 200, g: 0, b: 0);
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_0001u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(150, 0, 0), color);
}
/// <summary>Retail's own loop (@0x0047e759-0x0047e80f) accumulates
/// unconditionally and ALWAYS divides by the full num_pals — a per-
/// entry miss contributes (0,0,0) rather than shrinking the divisor
/// (GetColorFromPal's own miss path @0x005639b5 returns 0, it does not
/// skip the accumulation).</summary>
[Fact]
public void TryGetPalSetAverageColor_MissingIndividualPaletteContributesBlackNotSkip()
{
var palSets = new FakePalSetSource();
palSets.Add(0x0F00_0002u, 0x0400_0010u, 0x0400_0011u); // second id never registered in colors.
var colors = new FakeColorSource();
colors.Add(0x0400_0010u, 0xd0, r: 200, g: 100, b: 50);
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_0002u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
// (200+0)/2=100, (100+0)/2=50, (50+0)/2=25 — divided by the FULL
// count of 2, not 1.
Assert.Equal(new ChargenSwatchRgb(100, 50, 25), color);
}
/// <summary>Retail's explicit zero-init before the num_pals>0 guard
/// (@0x0047e790) — an empty-but-resolved PalSet is BLACK, not a miss.</summary>
[Fact]
public void TryGetPalSetAverageColor_EmptyPalSet_ReturnsTrueBlack()
{
var palSets = new FakePalSetSource();
palSets.Add(0x0F00_0003u); // zero palette ids.
var colors = new FakeColorSource();
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_0003u, ChargenSwatchColorResolver.HairSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(0, 0, 0), color);
}
[Fact]
public void TryGetPalSetAverageColor_UnresolvedPalSetId_ReturnsFalse()
{
var palSets = new FakePalSetSource();
var colors = new FakeColorSource();
bool ok = ChargenSwatchColorResolver.TryGetPalSetAverageColor(
palSets, colors, 0x0F00_DEADu, ChargenSwatchColorResolver.HairSampleIndex, out _);
Assert.False(ok);
}
// ── TryGetDirectColor (Eyes) ─────────────────────────────────────────
[Fact]
public void TryGetDirectColor_ReadsThePaletteDirectly_NoPalSetIndirection()
{
var colors = new FakeColorSource();
colors.Add(0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, r: 15, g: 63, b: 93);
bool ok = ChargenSwatchColorResolver.TryGetDirectColor(
colors, 0x0400_0099u, ChargenSwatchColorResolver.EyeSampleIndex, out ChargenSwatchRgb color);
Assert.True(ok);
Assert.Equal(new ChargenSwatchRgb(15, 63, 93), color);
}
[Fact]
public void TryGetDirectColor_UnresolvedPaletteId_ReturnsFalse()
{
var colors = new FakeColorSource();
bool ok = ChargenSwatchColorResolver.TryGetDirectColor(
colors, 0x0400_DEADu, ChargenSwatchColorResolver.EyeSampleIndex, out _);
Assert.False(ok);
}
// ── TryGetClothingSwatchPalSetId ─────────────────────────────────────
private static ChargenClothingTable MakeClothingTable(uint templateId, uint firstChoicePalSetId, uint secondChoicePalSetId)
{
var template = new ChargenClothingPaletteTemplate(
[
new ChargenClothingSubPaletteChoice(firstChoicePalSetId, [new ChargenClothingSubPaletteRange(0, 8)]),
new ChargenClothingSubPaletteChoice(secondChoicePalSetId, [new ChargenClothingSubPaletteRange(8, 8)]),
]);
return new ChargenClothingTable(
new Dictionary<uint, ChargenClothingBaseEffect>(),
new Dictionary<uint, ChargenClothingPaletteTemplate> { [templateId] = template });
}
[Fact]
public void TryGetClothingSwatchPalSetId_ReturnsTheFIRSTChoicesPalSetId()
{
var clothingTables = new FakeClothingTableSource();
clothingTables.Add(0x1900_0001u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u));
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_0001u, paletteTemplateId: 9u, out uint palSetId);
Assert.True(ok);
Assert.Equal(0x0F00_0010u, palSetId); // choices[0], not choices[1].
}
[Fact]
public void TryGetClothingSwatchPalSetId_UnresolvedClothingTable_ReturnsFalse()
{
var clothingTables = new FakeClothingTableSource();
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_DEADu, paletteTemplateId: 9u, out _);
Assert.False(ok);
}
[Fact]
public void TryGetClothingSwatchPalSetId_TemplateIdNotInThisGarmentsTable_ReturnsFalse()
{
var clothingTables = new FakeClothingTableSource();
clothingTables.Add(0x1900_0002u, MakeClothingTable(9u, 0x0F00_0010u, 0x0F00_0011u));
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_0002u, paletteTemplateId: 999u, out _);
Assert.False(ok);
}
[Fact]
public void TryGetClothingSwatchPalSetId_TemplateWithNoChoices_ReturnsFalse()
{
var clothingTables = new FakeClothingTableSource();
var emptyTemplate = new ChargenClothingPaletteTemplate([]);
clothingTables.Add(
0x1900_0003u,
new ChargenClothingTable(
new Dictionary<uint, ChargenClothingBaseEffect>(),
new Dictionary<uint, ChargenClothingPaletteTemplate> { [5u] = emptyTemplate }));
bool ok = ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId(
clothingTables, 0x1900_0003u, paletteTemplateId: 5u, out _);
Assert.False(ok);
}
// ── Fixed sample-index constants (decomp anchors, gmCGAppearancePage::SetSelection) ──
[Fact]
public void SampleIndexConstants_MatchTheDecompiledLiterals()
{
Assert.Equal(0xd0, ChargenSwatchColorResolver.HairSampleIndex);
Assert.Equal(0xb0, ChargenSwatchColorResolver.SkinFamilySampleIndex);
Assert.Equal(0x103, ChargenSwatchColorResolver.EyeSampleIndex);
Assert.Equal(0x520, ChargenSwatchColorResolver.ClothingSampleIndex);
}
}