fix(chargen): Campaign CC gate round 1 Batch D — gmCG3DView environment backdrop

Retail's chargen 3D views (Appearance and Summary) are not black behind
the model: gmCG3DView::Update @0x004EE9D0 constructs a SECOND CPhysicsObj
from the current heritage's HeritageGroup_CG.environmentSetupID field
(acclient.h verbatim struct layout; the decompiler elides the actual field
read, but HeritageGroup_CG::GetSubDataIDs @0x005c05d0 explicitly walks
iconImage/setupID/environmentSetupID by name, confirming the identity) and
adds it to the SAME viewport's creature_mode_objects the player object
lives in, inserted BEFORE the player (whose own re-AddObject happens much
later, at ~0x004ef199, after the full clothing ObjDesc composes). The
backdrop gets no explicit position/orientation/scale — CPhysicsObj::
makeObject(eax_32, 0, 1) leaves it at the scene origin with identity
orientation, same as the player object's own placement. This id was
already parsed as ChargenHeritageOptions.EnvironmentSetupId
(ChargenTableReader.cs) but never consumed anywhere in production (GF-7/
GF-14).

Fixed by:
- ChargenPreviewEntityBuilder.TryBuildBackdrop: builds a plain, unposed
  Setup mesh from the heritage's EnvironmentSetupId, returning null for
  id 0/unset or an unresolvable Setup (retail's own INVALID_DID gate).
- PrivateEntityViewportRenderer: an optional second entity slot
  (SetBackdrop), reserved via a backdropRenderId constructor parameter so
  paperdoll and creature-appraisal — which never pass one — cannot
  acquire a second entity even by accident (SetBackdrop throws without a
  reserved slot). Per-entity mesh-reference/texture-owner lifetime is
  factored into a private EntitySlot helper shared by both the main and
  backdrop slots. Draw-entity assembly is a pure, directly-testable
  helper (BuildDrawEntities) that puts the backdrop first, matching
  retail's own AddObject insertion order.
- ChargenPreviewController.Rebuild: rebuilds the backdrop whenever the
  HERITAGE changes (narrower than the existing camera-eye-reset gate,
  since environmentSetupID is a pure function of heritage, never gender
  or appearance selection).

Both Appearance and Summary get the fix from the same ChargenPreviewRenderer
facade — confirmed both pages call the identical gmCG3DView::Update on
their own gmCG3DView instance, so no page-specific code was needed.
Lighting was independently re-verified against the same function's
SetLight call (DISTANT_LIGHT, intensity 2.0, direction (0.3, 1.9, 0.65),
default white color) and found to already match byte-for-byte what CC6a
shipped.

Also files docs/ISSUES.md #409 for GF-16 (client-wide UI tooltip system),
investigated in the same root-cause pass but explicitly out of this
batch's scope, and marks it DEFERRED in the findings doc.

Tests: 11 new/extended (ChargenPreviewEntityBuilderTests.TryBuildBackdrop_*,
ChargenPreviewControllerTests backdrop rebuild/swap/absent/no-op cases,
PrivateEntityViewportRendererDrawOrderTests pinning the paperdoll/creature-
appraisal single-entity invariant). Live-DAT measurement: all 13 retail
heritages' EnvironmentSetupId resolve to a real, drawable installed Setup.

App suite 5307/3 -> 5321/3 (+14, 0 regressions). Runtime 1735/0 unchanged.
Launcher.Core.Tests 337/0 and Launcher.Tests 67/0 unchanged (first build of
the merged tree carrying the #406 launcher merge). Full solution: 14508
total / 14504 passed / 4 skipped / 0 failed, dotnet test exit code 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 13:05:23 +02:00
parent 0b05b58514
commit 63bf64c934
9 changed files with 866 additions and 103 deletions

View file

@ -24,6 +24,59 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1)
**Status:** OPEN
**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for ~253 authored elements client-wide and acdream shows none)
Found during Campaign CC gate round 1's Batch D root-cause investigation
(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`, GF-16
"Hover tooltips missing on all pages"). Explicitly out of Batch D's own
scope — Batch D fixed the chargen 3D preview backdrop (GF-7/GF-14) only;
GF-16 is a CLIENT-WIDE mechanism, not a chargen-scoped one, and needs its
own gate round the same way GF-12's frame carve-out and #408's
importer-wide honor did.
Retail's tooltip pipeline (decomp anchors from the Batch D
investigation):
- `UIElement::StartTooltipAtMouse @0x00460D70` — the per-element entry
point; fired from mouse-hover dispatch.
- `UIElementManager::StartTooltip @0x0045DE90` and a second call site
`@0x00459700` — the manager-level owner that actually builds/positions
the tooltip popup element and starts its show/delay timer.
- Layout DID `0x21000041` — the authored tooltip popup LayoutDesc (not yet
imported/mounted by `LayoutImporter`/`RetailUiRuntime`).
- Element properties `P0x47`/`P0x48`/`P0x49`/`P0x4A`/`P0x4B` — the five
per-element tooltip-text/behavior properties `UIElement::OnSetAttribute`
reads (exact semantics per property still need re-derivation when this
issue is picked up — the investigation only confirmed the property IDs,
not their individual meanings).
- Measured **~253 authored elements client-wide** carry at least one of
those five properties (a scope comparable to #408's 1,083-element sweep,
though a different property family).
- User-facing config: `Misc_TooltipEnable`/`Misc_TooltipDelay` prefs (the
Options-panel-adjacent settings that gate whether tooltips show at all
and how long the hover dwell is before one appears).
Fix direction, mirroring #408's own "own gate round" shape: (1) grep-named
first on all four decomp anchors above and re-derive the exact show/hide/
position/delay state machine (`StartTooltipAtMouse``StartTooltip`
popup lifecycle) before writing any pseudocode; (2) import/mount layout
`0x21000041` through the existing `LayoutImporter`/`RetailUiRuntime`
pipeline; (3) wire client-wide mouse-hover dispatch (likely through the
existing `InputDispatcher`/`UiRoot` hover-tracking, if any already exists,
or a new hover-timer owner otherwise) to read the five P0x47-P0x4B
properties per hovered element; (4) honor `Misc_TooltipEnable`/
`Misc_TooltipDelay` from `RuntimeCharacterOptionsState`/
`CharacterOptionTable` (Campaign OP's existing option-storage owner); (5)
a live-DAT sweep of the ~253 elements (same shape as #408's per-LayoutDesc
enumeration) before claiming full coverage, since a partial per-page
implementation would repeat the "accumulate a bigger partial table"
mistake #306 already named for a different subsystem; (6) its own
connected visual gate — hovering a representative sample across multiple
screens (chargen, main game UI, chat, Options) side-by-side with retail.
## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide)
**Status:** OPEN

View file

@ -41,6 +41,23 @@ first time this batch — the user's own visual check of both is owed
before considering Commit 2 closed; automated coverage cannot catch a
purely visual placement regression.**
**Batch D (chargen 3D preview backdrop) is CODE-COMPLETE 2026-08-16, pending
the user's visual gate.** GF-7 and GF-14 are fixed — see each entry's own
FIXED note below. GF-16 (client-wide tooltips) was investigated in the same
root-cause pass but is explicitly out of this batch's scope — deferred as
`docs/ISSUES.md` #409 with its own decomp anchors. Fixture + live-DAT tests
only this round (no graphical client launch); App suite went from 5307/3 to
5321/3 (+14, zero regressions), Runtime 1735/0 unchanged. Both Launcher test
projects (this being the first build of the merged tree carrying the #406
launcher merge) pass at their own baselines: Launcher.Core.Tests 337/0,
Launcher.Tests 67/0. Blast radius: `PrivateEntityViewportRenderer` (shared
with paperdoll and creature-appraisal) gained an OPTIONAL second entity
slot reserved via a `backdropRenderId` constructor parameter — paperdoll
and creature-appraisal never pass one, so their draw stays single-entity
by construction (`SetBackdrop` throws if called without a reserved slot,
and the entity-list-assembly helper `BuildDrawEntities` degrades to
exactly the main entity whenever no backdrop is configured/set).
User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session,
windowed). Screenshots: retail Heritage, acdream Heritage, retail
Profession. The user's side-by-side retail reports are AXIOMS
@ -236,10 +253,43 @@ ISSUES.md; this doc is the six-page batch.
AP-215's own icon-thumbnail item (the four icon-only spins still show no
per-choice icon art — a DIFFERENT, still-open gap) is rewritten, not
retired — see that row.
- **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14);
retail's chargen 3D view shows a scenic backdrop. (The Heritage-page
preview area shows terrain in BOTH clients — establish from the decomp
what actually renders behind the model per page/view.)
- **GF-7 Preview backdrop black on Appearance (and Summary, GF-14) — FIXED
(Campaign CC gate round 1, Batch D).** Root cause: retail's
`gmCG3DView::Update @0x004EE9D0` (~0x004eecd3-0x004eed44) constructs a
SECOND `CPhysicsObj` from the current heritage's own
`HeritageGroup_CG.environmentSetupID` field (verbatim struct layout,
`acclient.h`) and adds it to the SAME viewport's `creature_mode_objects`
the player object lives in — this codebase already parsed that id as
`ChargenHeritageOptions.EnvironmentSetupId` (`ChargenTableReader.cs`)
but never consumed it anywhere. The decompiler elides the actual field
read (`var_b8`/`eax_32`, an unresolved-call artifact — see
`claude-memory/feedback_bn_decomp_field_names.md`); cross-referencing
`acclient.h`'s `HeritageGroup_CG` struct (environmentSetupID sits right
after setupID) confirmed what the elided value is. The backdrop object
gets NO explicit position/orientation/scale — `CPhysicsObj::makeObject`
(0x004eed2f) leaves it at the scene origin with identity orientation,
same as the player object's own default placement, and retail's own
`AddObject` insertion order puts the backdrop BEFORE the player (the
player's own re-`AddObject` happens later, at ~0x004ef199, after the
full clothing ObjDesc composes). Fixed by extending
`ChargenPreviewEntityBuilder` with `TryBuildBackdrop` (builds a plain,
unposed Setup mesh from the heritage's `EnvironmentSetupId`, returning
null for id 0/unset or an unresolvable Setup — matching retail's own
`if (eax_32 != INVALID_DID.id)` gate at 0x004eed29), giving
`PrivateEntityViewportRenderer` an optional second entity slot
(`SetBackdrop`, reserved via a `backdropRenderId` constructor param so
paperdoll/creature-appraisal — which never pass one — cannot acquire a
second entity even by accident), and wiring `ChargenPreviewController`
to rebuild the backdrop whenever the HERITAGE changes (narrower than the
existing camera-eye-reset gate, since `environmentSetupID` is a pure
function of heritage, never gender or appearance selection). Both
Appearance and Summary get the fix from the SAME `ChargenPreviewRenderer`
facade — no page-specific code needed, confirmed both pages call the
identical `gmCG3DView::Update` on their own separate `gmCG3DView`
instance. Lighting was independently re-verified against the same
function's `SetLight` call (`DISTANT_LIGHT, 2.0, (0.3, 1.9, 0.65)`
direction, default white color) and found to ALREADY match byte-for-byte
what CC6a shipped — no lighting change was needed.
- **GF-8 Appearance Face/Clothes sub-tab selection unmarked — FIXED
(Campaign CC gate round 1, Batch B).** Same root and same fix as GF-1:
the Face (`0x100003A9`)/Clothes (`0x100003AA`) sub-tab buttons author
@ -306,14 +356,22 @@ ISSUES.md; this doc is the six-page batch.
this EXACT carve-out). Full App suite (5304 tests): zero regressions.
**The user's own visual check of chat + the main game UI is still owed**
— automated coverage cannot catch a purely visual placement regression.
- **GF-14 Summary paperdoll backdrop black** (same family as GF-7,
UNCHANGED, out of this batch's scope — the 3-D preview backdrop, not a
text-widget gap). **Summary textbox wrapper + scrollbar — FIXED (Batch
C, Commit 2 for the frame/build half, Commit 3 for the scrollbar LINK
and the how-to text's own content — see the Suspected-shared-roots
entry and Commit 3's own composition of `gmCGSummaryPage::SetHowToText`
into `0x10000404`).**
- **GF-14 Summary paperdoll backdrop black — FIXED (Campaign CC gate round
1, Batch D, same fix as GF-7 above — both pages call the identical
`gmCG3DView::Update` on their own `gmCG3DView` instance).** **Summary
textbox wrapper + scrollbar — FIXED (Batch C, Commit 2 for the
frame/build half, Commit 3 for the scrollbar LINK and the how-to text's
own content — see the Suspected-shared-roots entry and Commit 3's own
composition of `gmCGSummaryPage::SetHowToText` into `0x10000404`).**
- **GF-16 Hover tooltips missing on all pages** (retail pops tooltips).
DEFERRED to its own gate round — filed as
[`docs/ISSUES.md` #409](../ISSUES.md) with the decomp anchors
(`UIElement::StartTooltipAtMouse @0x00460D70`,
`UIElementManager::StartTooltip @0x0045DE90` + `@0x00459700`, layout DID
`0x21000041`, properties P0x47-P0x4B, ~253 authored elements, prefs
`Misc_TooltipEnable`/`Misc_TooltipDelay`) the Batch D investigation
surfaced. Out of Batch D's scope: it is a CLIENT-WIDE mechanism, not the
chargen 3D preview backdrop Batch D actually fixed (GF-7/GF-14 above).
## Suspected shared roots (to be CONFIRMED by the investigation, not assumed)
@ -349,7 +407,13 @@ ISSUES.md; this doc is the six-page batch.
distinct from the art/media commit, and NOT gated by the same art-
availability check `ActiveState` is). See each GF's own FIXED entry
above and the retired AP-222 / narrowed AP-215 register rows.
4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws.
4. ~~Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws.~~
CONFIRMED, CLOSED at Batch D: `gmCG3DView::Update`'s own
`m_pbgObject`/`m_bgSetupID` pair, sourced from the heritage's
`HeritageGroup_CG.environmentSetupID` field — already parsed into this
codebase as `ChargenHeritageOptions.EnvironmentSetupId` but never
consumed before this fix. See GF-7's own FIXED entry above for the full
decomp citation.
5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked
chargen screen.~~ CLOSED: focus/typing routing was never broken (live-
verified); the real cause was `RetailDialogFactory` never re-asserting

View file

@ -5,6 +5,7 @@ using AcDream.Content;
using AcDream.Core.CharGen;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter;
namespace AcDream.App.Rendering;
@ -318,6 +319,27 @@ internal sealed class ChargenPreviewController :
: ChargenPreviewCamera.ResolveDefaultEye(heritageId);
}
// Batch D (GF-7/GF-14): retail's own backdrop-rebuild gate
// (gmCG3DView::Update's `m_bgSetupID.id != eax_32` check) fires
// whenever the HERITAGE's own environmentSetupID differs from the
// one currently shown — and that value is a pure function of
// heritage (ACCharGenData::GetHG(mHeritageGroup).environmentSetupID),
// never gender. Narrower than heritageOrGenderChanged on purpose: a
// gender-only change (or an appearance-only change, which never
// reaches this branch at all) would otherwise pay a redundant Setup
// dat fetch + mesh-reference acquire/release for a backdrop that
// cannot have changed.
bool heritageChanged = !_hasComposed || heritageId != _lastHeritageId;
if (heritageChanged)
{
WorldEntity? backdrop =
options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)
? ChargenPreviewEntityBuilder.TryBuildBackdrop(
_dats, heritage!.EnvironmentSetupId, _datLock)
: null;
_renderer.SetBackdrop(backdrop);
}
// ChargenPreviewZoomController's animator dependency is required at
// construction (fix round F2) — a fresh animator means a fresh
// controller, but it reads IsZoomedIn straight through the animator
@ -364,8 +386,11 @@ internal sealed class ChargenPreviewController :
// the leased renderer holding it until the renderer's OWN disposal
// (a separate manifest entry, one step later) — this class built
// the entity via Rebuild, so it releases it on its own teardown
// instead of relying on a downstream owner to notice.
// instead of relying on a downstream owner to notice. Batch D: the
// backdrop entity is the SAME kind of controller-built resource, so
// it releases on the same teardown for the same reason.
_renderer.SetPreview(null);
_renderer.SetBackdrop(null);
_animator = null;
_zoom = null;
// The renderer itself is a leased composition resource disposed by

View file

@ -104,6 +104,19 @@ internal static class ChargenPreviewEntityBuilder
/// doc comment.</summary>
public const uint PreviewRenderId = 0xDA11_D032u;
/// <summary>Reserved synthetic guid for the chargen preview's ENVIRONMENT
/// backdrop (GF-7/GF-14 fix) — next slot in the same 0xDA11D03x chargen
/// family as <see cref="PreviewServerGuid"/>.</summary>
public const uint PreviewBackdropServerGuid = 0xDA11_D033u;
/// <summary>Reserved render-local entity id for the backdrop object,
/// passed in <c>animatedEntityIds</c> alongside <see cref="PreviewRenderId"/>
/// so a heritage switch's new environment Setup also bypasses the
/// classification cache — same reasoning as <see cref="PreviewRenderId"/>'s
/// own doc comment, applied to retail's SECOND <c>creature_mode_objects</c>
/// member (<c>gmCG3DView::m_pbgObject</c>).</summary>
public const uint PreviewBackdropRenderId = 0xDA11_D034u;
/// <summary>
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
@ -301,6 +314,92 @@ internal static class ChargenPreviewEntityBuilder
};
}
/// <summary>
/// Builds the chargen preview's ENVIRONMENT BACKDROP entity — the fix for
/// GF-7/GF-14 (preview backdrop black on Appearance and Summary).
///
/// <para>
/// Decomp-cited: <c>gmCG3DView::Update @0x004EE9D0</c>
/// (~0x004eecd3-0x004eed44) constructs a SECOND <c>CPhysicsObj</c> from
/// <c>m_bgSetupID</c> and adds it to the SAME viewport's
/// <c>creature_mode_objects</c> the player object lives in — <b>BEFORE</b>
/// the player is re-added (the player's own re-<c>AddObject</c> happens
/// much later, at ~0x004ef199, after the full clothing ObjDesc is
/// composed), so retail's own draw-list order is backdrop first, player
/// second. <c>m_bgSetupID</c> is compared against a freshly-read value the
/// decompiler elides (<c>var_b8</c>/<c>eax_32</c>, an unresolved-call
/// artifact — see <c>claude-memory/feedback_bn_decomp_field_names.md</c>)
/// immediately after <c>ACCharGenData::GetHG(charGenData, mHeritageGroup)</c>
/// (0x004eea1a) resolves the current heritage's <c>HeritageGroup_CG</c>;
/// <c>acclient.h</c>'s verbatim struct layout
/// (<c>HeritageGroup_CG.environmentSetupID</c>, right after
/// <c>setupID</c>) confirms the elided value IS that field — i.e. THE
/// SAME id this codebase already parses as
/// <see cref="AcDream.Core.CharGen.ChargenHeritageOptions.EnvironmentSetupId"/>
/// (<c>ChargenTableReader.cs</c>) but never consumed. The backdrop object
/// gets NO explicit position/orientation/scale anywhere in the function —
/// <c>CPhysicsObj::makeObject(eax_32, 0, 1)</c> (0x004eed2f) leaves it at
/// its physics-object default (origin, identity), exactly like the player
/// object's own placement in this same private scene. This method mirrors
/// that: a plain, unposed, unpalette-overridden Setup mesh at the origin.
/// </para>
///
/// <para>
/// Both the Appearance page (<c>gmCGAppearancePage</c>) and the Summary
/// page (<c>gmCGSummaryPage</c>) call this SAME <c>gmCG3DView::Update</c>
/// function on their own <c>gmCG3DView</c> instance (confirmed at
/// pseudo-C ~0x0047bbf0/~0x0047c92c for Summary and ~0x0047c840/
/// ~0x0047eee1 for Appearance) — so the backdrop mechanism is identical
/// for both viewports, not page-specific.
/// </para>
/// </summary>
/// <param name="environmentSetupId">
/// <see cref="AcDream.Core.CharGen.ChargenHeritageOptions.EnvironmentSetupId"/>.
/// Zero (unset/no environment authored for this heritage) returns null —
/// matches retail's own <c>if (eax_32 != INVALID_DID.id)</c> gate at
/// 0x004eed29, which skips <c>makeObject</c>/<c>AddObject</c> entirely
/// when the heritage has no environment Setup.
/// </param>
public static WorldEntity? TryBuildBackdrop(
IDatReaderWriter dats,
uint environmentSetupId,
object datLock)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(datLock);
if (environmentSetupId == 0u)
return null;
lock (datLock)
{
Setup? setup = dats.Get<Setup>(environmentSetupId);
if (setup is null)
return null;
var flattened = SetupMesh.Flatten(setup);
var drawable = new List<MeshRef>(flattened.Count);
foreach (MeshRef part in flattened)
{
if (dats.Get<GfxObj>(part.GfxObjId) is not null)
drawable.Add(part);
}
if (drawable.Count == 0)
return null;
return new WorldEntity
{
Id = PreviewBackdropRenderId,
ServerGuid = PreviewBackdropServerGuid,
SourceGfxObjOrSetupId = environmentSetupId,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = drawable,
ParentCellId = null,
};
}
}
/// <summary>No dat access — pure projection of the already-composed
/// ObjDesc's subpalettes, safe to call outside <c>datLock</c>.</summary>
private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance)

View file

@ -14,6 +14,15 @@ internal interface IChargenPreviewRenderer
{
void SetPreview(WorldEntity? entity);
/// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the
/// preview (Campaign CC gate round 1 Batch D, GF-7/GF-14) — retail's
/// <c>gmCG3DView::m_pbgObject</c>. See
/// <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/> for the
/// decomp-cited placement.
/// </summary>
void SetBackdrop(WorldEntity? entity);
uint Render(int width, int height);
}
@ -91,7 +100,11 @@ internal sealed class ChargenPreviewRenderer :
meshAdapter,
ChargenPreviewEntityBuilder.PreviewRenderId,
_camera,
"chargen preview");
"chargen preview",
// Batch D (GF-7/GF-14): reserves the second draw-entity slot for
// the heritage's environment Setup — see PrivateEntityViewportRenderer's
// own doc comment on backdropRenderId.
ChargenPreviewEntityBuilder.PreviewBackdropRenderId);
}
public bool TextureIsBottomUp => _renderer.TextureIsBottomUp;
@ -105,6 +118,8 @@ internal sealed class ChargenPreviewRenderer :
public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity);
public void SetBackdrop(WorldEntity? entity) => _renderer.SetBackdrop(entity);
public uint Render(int width, int height) => _renderer.Render(width, height);
public void Dispose() => _renderer.Dispose();

View file

@ -44,6 +44,20 @@ internal interface IPrivateEntityViewportCamera : ICamera
/// raw-GL <c>WbDrawDispatcher</c> into (through V10, §5.5.6) was deleted at
/// Campaign V slice V11: <c>WbDrawDispatcher</c> now records into the pass
/// this renderer publishes on both call sites the same way.</para>
///
/// <para>
/// <b>Campaign CC gate round 1, Batch D (GF-7/GF-14).</b> Retail's
/// <c>gmCG3DView::Update @0x004EE9D0</c> draws a SECOND private entity — a
/// heritage-authored environment Setup (<c>m_pbgObject</c>) — behind the main
/// one, in the SAME <c>creature_mode_objects</c> list. This renderer now
/// supports that as an OPTIONAL second entity slot, reserved at construction
/// via <paramref name="backdropRenderId"/>-shaped ctor param (see below) —
/// paperdoll and creature-appraisal never pass one, so <see cref="SetBackdrop"/>
/// throws for them rather than silently doing nothing (the slot does not
/// exist). See <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/> for
/// the full decomp citation of the backdrop's placement (unposed, at the
/// scene origin, added to the draw list BEFORE the main entity).
/// </para>
/// </summary>
internal sealed class PrivateEntityViewportRenderer :
IUiViewportRenderer,
@ -68,21 +82,23 @@ internal sealed class PrivateEntityViewportRenderer :
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly IWbMeshAdapter _meshAdapter;
private readonly IPrivateEntityViewportCamera _camera;
private readonly HashSet<uint> _animatedIds;
private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner>
_retiringMeshReferences = [];
private readonly EntitySlot _mainSlot;
/// <summary>Null for every renderer that never reserved a
/// <c>backdropRenderId</c> (paperdoll, creature-appraisal) — the backdrop
/// feature does not exist for them, not just "unused".</summary>
private readonly EntitySlot? _backdropSlot;
private IGpuRenderTarget? _target;
private IGpuSampler? _sampler;
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
private int _fbW;
private int _fbH;
private WorldEntity? _entity;
private SyntheticEntityMeshReferenceOwner? _meshReferences;
public PrivateEntityViewportRenderer(
IWorldPassScope scope,
@ -94,10 +110,13 @@ internal sealed class PrivateEntityViewportRenderer :
IWbMeshAdapter meshAdapter,
uint renderId,
IPrivateEntityViewportCamera camera,
string diagnosticName)
string diagnosticName,
uint? backdropRenderId = null)
{
if (renderId == 0u)
throw new ArgumentOutOfRangeException(nameof(renderId));
if (backdropRenderId == 0u)
throw new ArgumentOutOfRangeException(nameof(backdropRenderId));
_scope = scope ?? throw new ArgumentNullException(
nameof(scope),
@ -112,10 +131,18 @@ internal sealed class PrivateEntityViewportRenderer :
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
? "creature viewport"
: diagnosticName;
_animatedIds = [renderId];
_textureOwnerLease = new FixedEntityTextureOwnerLease(
textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)),
renderId);
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
?? throw new ArgumentNullException(nameof(textureLifetime));
_mainSlot = new EntitySlot(_meshAdapter, textureLifetimeChecked, renderId, _diagnosticName);
_backdropSlot = backdropRenderId is uint backdropId
? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop")
: null;
_animatedIds = backdropRenderId is uint animatedBackdropId
? [renderId, animatedBackdropId]
: [renderId];
}
/// <summary>
@ -125,65 +152,27 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
public bool TextureIsBottomUp => false;
public void SetEntity(WorldEntity? entity)
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
/// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the main
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
/// valid on a renderer constructed with a <c>backdropRenderId</c>
/// (<see cref="ChargenPreviewRenderer"/>'s own construction); calling this
/// on a renderer that never reserved one (paperdoll, creature-appraisal)
/// throws — the slot does not exist for them, so there is nothing to make
/// "inert" by silently ignoring the call instead.
/// </summary>
public void SetBackdrop(WorldEntity? entity)
{
ReleaseRetiringMeshReferences();
if (ReferenceEquals(_entity, entity))
return;
SyntheticEntityMeshReferenceOwner? replacement = null;
if (entity is not null)
if (_backdropSlot is null)
{
replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
CollectMeshIds(entity));
replacement.Acquire();
throw new InvalidOperationException(
$"The {_diagnosticName} was not constructed with a "
+ "backdropRenderId and cannot render a second (backdrop) entity.");
}
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
try
{
_textureOwnerLease.Replace(entity is not null);
}
catch (Exception textureFailure)
{
if (replacement is null)
throw;
try
{
replacement.Dispose();
}
catch (Exception rollbackFailure)
{
throw new AggregateException(
$"The {_diagnosticName} texture-owner replacement failed "
+ "and the replacement mesh-owner rollback did not converge.",
textureFailure,
rollbackFailure);
}
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(textureFailure)
.Throw();
}
_meshReferences = replacement;
_entity = entity;
if (previous is not null)
{
try
{
previous.Dispose();
}
catch
{
_retiringMeshReferences.Add(previous);
throw;
}
}
_backdropSlot.Set(entity);
}
/// <summary>
@ -193,7 +182,7 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
public uint Render(int width, int height)
{
WorldEntity? entity = _entity;
WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
@ -232,7 +221,7 @@ internal sealed class PrivateEntityViewportRenderer :
UploadCreatureLight();
WorldEntity[] entities = [entity];
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
var entries =
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[]
@ -241,7 +230,7 @@ internal sealed class PrivateEntityViewportRenderer :
PrivateLandblockId,
new Vector3(-1024f),
new Vector3(1024f),
entities,
drawEntities,
null),
};
@ -255,9 +244,34 @@ internal sealed class PrivateEntityViewportRenderer :
return UiTextureTableHandle.FromSlot(_slot);
}
/// <summary>
/// Pure helper assembling this frame's draw-entity list in retail's own
/// insertion order — <c>gmCG3DView::Update</c> adds the backdrop object to
/// <c>creature_mode_objects</c> BEFORE the main (player) object is
/// re-added (the player's own re-<c>AddObject</c> happens much later, at
/// ~0x004ef199, after the full clothing ObjDesc composes — see
/// <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/>'s own decomp
/// citation). A null or empty-meshed backdrop degrades to exactly the main
/// entity — this is the paperdoll/creature-appraisal invariant (they never
/// configure a backdrop slot at all, so this always takes this branch for
/// them), pinned directly by
/// <c>PrivateEntityViewportRendererDrawOrderTests</c> without needing a
/// live GPU device or a constructed <see cref="WbDrawDispatcher"/>.
/// </summary>
internal static IReadOnlyList<WorldEntity> BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) =>
backdrop is not null && backdrop.MeshRefs.Count > 0
? [backdrop, main]
: [main];
/// <summary>
/// Both retail paperdoll and creature examination call
/// <c>UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65))</c>.
/// Byte-decoded confirmation (Batch D re-derivation): the SAME three
/// float32 constants (<c>0x3e99999a</c>/<c>0x3ff33333</c>/<c>0x3F266666</c>
/// = 0.3/1.9/0.65) appear verbatim at <c>gmCG3DView::Update</c>'s own
/// <c>SetLight</c> call site (pseudo-C ~0x004eecd3-0x004eece3) — the
/// chargen preview uses the EXACT same light this method already ported,
/// not a different value.
/// </summary>
private void UploadCreatureLight()
{
@ -342,17 +356,10 @@ internal sealed class PrivateEntityViewportRenderer :
public void Dispose()
{
_entity = null;
if (_meshReferences is { } current)
{
_meshReferences = null;
_retiringMeshReferences.Add(current);
}
List<Exception>? failures = null;
try
{
_textureOwnerLease.Dispose();
_mainSlot.Dispose();
}
catch (Exception error)
{
@ -360,7 +367,7 @@ internal sealed class PrivateEntityViewportRenderer :
}
try
{
ReleaseRetiringMeshReferences();
_backdropSlot?.Dispose();
}
catch (Exception error)
{
@ -391,30 +398,158 @@ internal sealed class PrivateEntityViewportRenderer :
yield return entity.PartOverrides[i].GfxObjId;
}
private void ReleaseRetiringMeshReferences()
/// <summary>
/// One private entity's own mesh-reference/texture-owner lifetime,
/// independent of any other slot on the same renderer. Factored out at
/// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets
/// the EXACT SAME acquire/replace/retire behavior the main entity already
/// had — a single-owner class shared by both slots rather than a second,
/// hand-duplicated copy of <see cref="PrivateEntityViewportRenderer.SetEntity"/>'s
/// pre-Batch-D body.
/// </summary>
private sealed class EntitySlot
{
List<Exception>? failures = null;
for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--)
private readonly IWbMeshAdapter _meshAdapter;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner> _retiringMeshReferences = [];
private SyntheticEntityMeshReferenceOwner? _meshReferences;
public EntitySlot(
IWbMeshAdapter meshAdapter,
IEntityTextureLifetime textureLifetime,
uint ownerLocalId,
string diagnosticName)
{
SyntheticEntityMeshReferenceOwner owner =
_retiringMeshReferences[i];
_meshAdapter = meshAdapter;
_textureOwnerLease = new FixedEntityTextureOwnerLease(textureLifetime, ownerLocalId);
_diagnosticName = diagnosticName;
}
public WorldEntity? Entity { get; private set; }
public void Set(WorldEntity? entity)
{
ReleaseRetiringMeshReferences();
if (ReferenceEquals(Entity, entity))
return;
SyntheticEntityMeshReferenceOwner? replacement = null;
if (entity is not null)
{
replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
CollectMeshIds(entity));
replacement.Acquire();
}
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
try
{
owner.Dispose();
if (owner.IsDisposed)
_retiringMeshReferences.RemoveAt(i);
_textureOwnerLease.Replace(entity is not null);
}
catch (Exception textureFailure)
{
if (replacement is null)
throw;
try
{
replacement.Dispose();
}
catch (Exception rollbackFailure)
{
throw new AggregateException(
$"The {_diagnosticName} texture-owner replacement failed "
+ "and the replacement mesh-owner rollback did not converge.",
textureFailure,
rollbackFailure);
}
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(textureFailure)
.Throw();
}
_meshReferences = replacement;
Entity = entity;
if (previous is not null)
{
try
{
previous.Dispose();
}
catch
{
_retiringMeshReferences.Add(previous);
throw;
}
}
}
public void Dispose()
{
Entity = null;
if (_meshReferences is { } current)
{
_meshReferences = null;
_retiringMeshReferences.Add(current);
}
List<Exception>? failures = null;
try
{
_textureOwnerLease.Dispose();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
try
{
ReleaseRetiringMeshReferences();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
if (failures is not null)
{
throw new AggregateException(
$"The {_diagnosticName} resources did not fully release.",
failures);
}
}
if (failures is not null)
private void ReleaseRetiringMeshReferences()
{
throw new AggregateException(
$"One or more {_diagnosticName} mesh owners remain pending.",
failures);
List<Exception>? failures = null;
for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--)
{
SyntheticEntityMeshReferenceOwner owner =
_retiringMeshReferences[i];
try
{
owner.Dispose();
if (owner.IsDisposed)
_retiringMeshReferences.RemoveAt(i);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
{
throw new AggregateException(
$"One or more {_diagnosticName} mesh owners remain pending.",
failures);
}
}
}
}

View file

@ -204,6 +204,168 @@ public sealed class ChargenPreviewControllerTests
}
}
/// <summary>
/// Campaign CC gate round 1, Batch D (GF-7/GF-14): a successful Rebuild
/// against a heritage that authors an environment Setup pushes a non-null
/// backdrop entity to the renderer, sourced from the heritage's own
/// <c>EnvironmentSetupId</c>.
/// </summary>
[InstalledDatFact]
public void Rebuild_HeritageWithEnvironmentSetupId_SetsANonNullBackdrop()
{
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
return;
using (dats)
using (adapter)
{
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian));
if (aluvian!.EnvironmentSetupId == 0u)
{
_out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId.");
return;
}
var renderer = new FakeChargenRenderer();
var view = new FakeChargenView();
var controller = new ChargenPreviewController(
renderer, new ChargenPreviewCamera(), view,
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
Assert.True(controller.Rebuild(
options, AluvianId, 1, DefaultSelection(options, AluvianId, 1)));
Assert.Equal(1, renderer.SetBackdropCallCount);
Assert.NotNull(renderer.LastBackdropEntity);
Assert.Equal(aluvian.EnvironmentSetupId, renderer.LastBackdropEntity!.SourceGfxObjOrSetupId);
}
}
/// <summary>Retail's own gate (0x004eed29) skips the backdrop object
/// entirely for a heritage with no authored environment Setup — the
/// controller must leave the renderer's backdrop null, not build an empty
/// placeholder entity.</summary>
[InstalledDatFact]
public void Rebuild_HeritageWithNoEnvironmentSetupId_LeavesTheBackdropAbsent()
{
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
return;
using (dats)
using (adapter)
{
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian));
// Synthetic zero-EnvironmentSetupId heritage, otherwise identical
// to the real installed Aluvian entry (so ChargenAppearanceFactory
// .TryCompose still succeeds against the SAME options instance) —
// proves the absent-when-unset path without depending on the
// installed dat happening to have an unset heritage.
var heritages = new Dictionary<uint, ChargenHeritageOptions>(options.HeritagesById)
{
[AluvianId] = aluvian! with { EnvironmentSetupId = 0u },
};
ChargenOptions zeroed = options with { HeritagesById = heritages };
var renderer = new FakeChargenRenderer();
var view = new FakeChargenView();
var controller = new ChargenPreviewController(
renderer, new ChargenPreviewCamera(), view,
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
Assert.True(controller.Rebuild(
zeroed, AluvianId, 1, DefaultSelection(zeroed, AluvianId, 1)));
Assert.Equal(1, renderer.SetBackdropCallCount);
Assert.Null(renderer.LastBackdropEntity);
}
}
/// <summary>
/// The backdrop swaps to the new heritage's own environment Setup on a
/// heritage change — the SAME <c>gmCG3DView::Update</c> gate
/// (<c>m_bgSetupID.id != eax_32</c>) that drives the main entity's own
/// re-dress.
/// </summary>
[InstalledDatFact]
public void Rebuild_HeritageChange_SwapsTheBackdropEntity()
{
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
return;
using (dats)
using (adapter)
{
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
Assert.True(options.TryGetHeritage(AluvianId, out ChargenHeritageOptions? aluvian));
if (aluvian!.EnvironmentSetupId == 0u)
{
_out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId.");
return;
}
if (!options.TryGetHeritage(GearknightId, out ChargenHeritageOptions? gearknight)
|| gearknight!.GendersByKey.Count == 0
|| gearknight.EnvironmentSetupId == 0u)
{
_out.WriteLine("SKIP: installed dat has no usable Gearknight environment/gender to switch to.");
return;
}
var renderer = new FakeChargenRenderer();
var view = new FakeChargenView();
var controller = new ChargenPreviewController(
renderer, new ChargenPreviewCamera(), view,
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
Assert.True(controller.Rebuild(
options, AluvianId, 1, DefaultSelection(options, AluvianId, 1)));
WorldEntity? firstBackdrop = renderer.LastBackdropEntity;
Assert.NotNull(firstBackdrop);
int gearknightGender = gearknight.GendersByKey.Keys.First();
Assert.True(controller.Rebuild(
options, GearknightId, gearknightGender,
DefaultSelection(options, GearknightId, gearknightGender)));
Assert.Equal(2, renderer.SetBackdropCallCount);
Assert.NotSame(firstBackdrop, renderer.LastBackdropEntity);
Assert.NotNull(renderer.LastBackdropEntity);
Assert.Equal(
gearknight.EnvironmentSetupId, renderer.LastBackdropEntity!.SourceGfxObjOrSetupId);
}
}
/// <summary>
/// Decomp-cited: the heritage's own <c>environmentSetupID</c> is a pure
/// function of heritage (<c>HeritageGroup_CG</c>), never gender or
/// appearance selection — an appearance-only Rebuild must not re-touch
/// the backdrop at all.
/// </summary>
[InstalledDatFact]
public void Rebuild_AppearanceOnlyChange_DoesNotRebuildTheBackdrop()
{
if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter))
return;
using (dats)
using (adapter)
{
(ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!);
var renderer = new FakeChargenRenderer();
var view = new FakeChargenView();
var controller = new ChargenPreviewController(
renderer, new ChargenPreviewCamera(), view,
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1);
Assert.True(controller.Rebuild(options, AluvianId, 1, first));
Assert.Equal(1, renderer.SetBackdropCallCount);
ChargenAppearanceSelection second = first with { SkinShade = 0.9 };
Assert.True(controller.Rebuild(options, AluvianId, 1, second));
Assert.Equal(1, renderer.SetBackdropCallCount);
}
}
[InstalledDatFact]
public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication()
{
@ -272,12 +434,24 @@ public sealed class ChargenPreviewControllerTests
public int SetPreviewCallCount { get; private set; }
public int RenderCallCount { get; private set; }
/// <summary>Batch D (GF-7/GF-14): last value passed to <see cref="SetBackdrop"/>.
/// Null both before the first call AND after an explicit clear — tests
/// distinguish the two via <see cref="SetBackdropCallCount"/>.</summary>
public WorldEntity? LastBackdropEntity { get; private set; }
public int SetBackdropCallCount { get; private set; }
public void SetPreview(WorldEntity? entity)
{
LastEntity = entity;
SetPreviewCallCount++;
}
public void SetBackdrop(WorldEntity? entity)
{
LastBackdropEntity = entity;
SetBackdropCallCount++;
}
public uint Render(int width, int height)
{
RenderCallCount++;

View file

@ -275,4 +275,119 @@ public sealed class ChargenPreviewEntityBuilderTests
Assert.False(animator.IsZoomedIn);
Assert.NotEmpty(animator.Entity.MeshRefs);
}
/// <summary>
/// Campaign CC gate round 1, Batch D (GF-7/GF-14): the ENVIRONMENT
/// backdrop entity resolves against the installed dat for a real
/// heritage. Aluvian's own <c>EnvironmentSetupId</c> was already parsed
/// (<see cref="ChargenTableReader"/>) but never consumed before this fix.
/// </summary>
[Fact]
public void TryBuildBackdrop_AluvianHeritage_ResolvesANonEmptyMesh()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.True(options.TryGetHeritage(1u, out ChargenHeritageOptions? aluvian)); // Aluvian.
if (aluvian!.EnvironmentSetupId == 0u)
{
_out.WriteLine("SKIP: installed dat's Aluvian heritage authors no EnvironmentSetupId.");
return;
}
var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop(
adapter, aluvian.EnvironmentSetupId, new object());
Assert.NotNull(entity);
Assert.NotEmpty(entity!.MeshRefs);
Assert.Equal(aluvian.EnvironmentSetupId, entity.SourceGfxObjOrSetupId);
Assert.Equal(ChargenPreviewEntityBuilder.PreviewBackdropServerGuid, entity.ServerGuid);
Assert.Equal(ChargenPreviewEntityBuilder.PreviewBackdropRenderId, entity.Id);
// Decomp-cited (gmCG3DView::Update ~0x004eed2f): CPhysicsObj::makeObject
// never receives an explicit position/orientation for the backdrop —
// it sits at the private scene's origin with identity orientation,
// same as the player object's own default placement.
Assert.Equal(Vector3.Zero, entity.Position);
Assert.Equal(Quaternion.Identity, entity.Rotation);
_out.WriteLine($"backdropSetup=0x{aluvian.EnvironmentSetupId:X8} meshRefs={entity.MeshRefs.Count}");
}
/// <summary>Retail's own gate at 0x004eed29 (<c>if (eax_32 != INVALID_DID.id)</c>)
/// skips creating a backdrop object entirely when the heritage authors no
/// environment Setup — id 0/unset must return null, not an empty entity.</summary>
[Fact]
public void TryBuildBackdrop_UnsetEnvironmentSetupId_ReturnsNull()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop(adapter, 0u, new object());
Assert.Null(entity);
}
[Fact]
public void TryBuildBackdrop_UnknownSetupId_ReturnsNull()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop(
adapter, 0x0200_FFFFu, new object());
Assert.Null(entity);
}
/// <summary>
/// Live-DAT measurement (not assumed): every one of the 13 retail
/// heritages' <c>EnvironmentSetupId</c> resolves to a real installed
/// Setup with at least one drawable part. Reports precisely which
/// heritage(s) don't, if any, instead of assuming full coverage.
/// </summary>
[Fact]
public void TryBuildBackdrop_AllThirteenHeritages_ResolveOrAreReportedByName()
{
string? datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ChargenOptions options = ChargenTableReader.Load(adapter);
Assert.Equal(13, options.HeritagesById.Count);
var unresolved = new List<string>();
foreach (KeyValuePair<uint, ChargenHeritageOptions> pair in options.HeritagesById)
{
ChargenHeritageOptions heritage = pair.Value;
var entity = ChargenPreviewEntityBuilder.TryBuildBackdrop(
adapter, heritage.EnvironmentSetupId, new object());
if (entity is null)
{
unresolved.Add(
$"{heritage.Name} (id={pair.Key}, environmentSetupId=0x{heritage.EnvironmentSetupId:X8})");
}
}
_out.WriteLine(unresolved.Count == 0
? "All 13 heritages resolved a drawable environment backdrop."
: "Unresolved: " + string.Join("; ", unresolved));
// Measured, not assumed: report the exact set rather than asserting
// blind 13/13 in case the installed dat is missing one.
Assert.True(
unresolved.Count <= 13,
"Sanity bound only — the WriteLine above is the real measurement.");
}
}

View file

@ -0,0 +1,83 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.World;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign CC gate round 1, Batch D (GF-7/GF-14): pins
/// <see cref="PrivateEntityViewportRenderer.BuildDrawEntities"/>, the pure
/// helper that decides which entities <see cref="PrivateEntityViewportRenderer.Render"/>
/// submits to <c>WbDrawDispatcher</c>. Exercised directly (no GPU device, no
/// constructed <c>WbDrawDispatcher</c>) — the same interface-fake-first
/// testing shape <c>CreatureAppraisalPresentationTests</c> already uses for
/// this renderer family, since <c>PrivateEntityViewportRenderer</c> itself
/// pulls in a live mesh-pipeline object graph too heavy to construct in a
/// unit test.
///
/// <para>
/// This is the paperdoll/creature-appraisal REGRESSION PIN the batch's test
/// plan calls for: both renderers never configure a backdrop slot (see
/// <c>PaperdollViewportRenderer</c>/<c>CreatureAppraisalPresentation.cs</c> —
/// neither passes a <c>backdropRenderId</c> nor exposes <c>SetBackdrop</c>),
/// so every one of their draws calls this helper with <c>backdrop: null</c> —
/// exactly the first case below.
/// </para>
/// </summary>
public sealed class PrivateEntityViewportRendererDrawOrderTests
{
private static WorldEntity Entity(uint id, IReadOnlyList<MeshRef> meshRefs) => new()
{
Id = id,
ServerGuid = id,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = meshRefs,
};
private static readonly MeshRef[] OneMesh = [new MeshRef(0x0100_0001u, Matrix4x4.Identity)];
[Fact]
public void NoBackdrop_ReturnsExactlyTheMainEntity()
{
WorldEntity main = Entity(1u, OneMesh);
IReadOnlyList<WorldEntity> entities =
PrivateEntityViewportRenderer.BuildDrawEntities(backdrop: null, main);
Assert.Same(main, Assert.Single(entities));
}
[Fact]
public void BackdropPresent_ReturnsBackdropFirstThenMain()
{
// Decomp-cited: gmCG3DView::Update adds the backdrop object to
// creature_mode_objects BEFORE the player object is re-added
// (~0x004eed44 vs ~0x004ef199) — this ordering is retail-faithful,
// not an arbitrary choice.
WorldEntity backdrop = Entity(ChargenPreviewEntityBuilder.PreviewBackdropRenderId, OneMesh);
WorldEntity main = Entity(ChargenPreviewEntityBuilder.PreviewRenderId, OneMesh);
IReadOnlyList<WorldEntity> entities =
PrivateEntityViewportRenderer.BuildDrawEntities(backdrop, main);
Assert.Equal(2, entities.Count);
Assert.Same(backdrop, entities[0]);
Assert.Same(main, entities[1]);
}
[Fact]
public void BackdropWithNoDrawableMeshes_DegradesToExactlyTheMainEntity()
{
WorldEntity emptyBackdrop = Entity(
ChargenPreviewEntityBuilder.PreviewBackdropRenderId, []);
WorldEntity main = Entity(ChargenPreviewEntityBuilder.PreviewRenderId, OneMesh);
IReadOnlyList<WorldEntity> entities =
PrivateEntityViewportRenderer.BuildDrawEntities(emptyBackdrop, main);
Assert.Same(main, Assert.Single(entities));
}
}