diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 39f1ed62..7c9515a2 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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
diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
index ddd591b8..27d65ed5 100644
--- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
+++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
@@ -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
diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs
index 4727cecb..42d4cfca 100644
--- a/src/AcDream.App/Rendering/ChargenPreviewController.cs
+++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs
@@ -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
diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
index df21c5a6..fdf74162 100644
--- a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
+++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
@@ -104,6 +104,19 @@ internal static class ChargenPreviewEntityBuilder
/// doc comment.
public const uint PreviewRenderId = 0xDA11_D032u;
+ /// Reserved synthetic guid for the chargen preview's ENVIRONMENT
+ /// backdrop (GF-7/GF-14 fix) — next slot in the same 0xDA11D03x chargen
+ /// family as .
+ public const uint PreviewBackdropServerGuid = 0xDA11_D033u;
+
+ /// Reserved render-local entity id for the backdrop object,
+ /// passed in animatedEntityIds alongside
+ /// so a heritage switch's new environment Setup also bypasses the
+ /// classification cache — same reasoning as 's
+ /// own doc comment, applied to retail's SECOND creature_mode_objects
+ /// member (gmCG3DView::m_pbgObject).
+ public const uint PreviewBackdropRenderId = 0xDA11_D034u;
+
///
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid
@@ -301,6 +314,92 @@ internal static class ChargenPreviewEntityBuilder
};
}
+ ///
+ /// Builds the chargen preview's ENVIRONMENT BACKDROP entity — the fix for
+ /// GF-7/GF-14 (preview backdrop black on Appearance and Summary).
+ ///
+ ///
+ /// Decomp-cited: gmCG3DView::Update @0x004EE9D0
+ /// (~0x004eecd3-0x004eed44) constructs a SECOND CPhysicsObj from
+ /// m_bgSetupID and adds it to the SAME viewport's
+ /// creature_mode_objects the player object lives in — BEFORE
+ /// the player is re-added (the player's own re-AddObject happens
+ /// much later, at ~0x004ef199, after the full clothing ObjDesc is
+ /// composed), so retail's own draw-list order is backdrop first, player
+ /// second. m_bgSetupID is compared against a freshly-read value the
+ /// decompiler elides (var_b8/eax_32, an unresolved-call
+ /// artifact — see claude-memory/feedback_bn_decomp_field_names.md)
+ /// immediately after ACCharGenData::GetHG(charGenData, mHeritageGroup)
+ /// (0x004eea1a) resolves the current heritage's HeritageGroup_CG;
+ /// acclient.h's verbatim struct layout
+ /// (HeritageGroup_CG.environmentSetupID, right after
+ /// setupID) confirms the elided value IS that field — i.e. THE
+ /// SAME id this codebase already parses as
+ ///
+ /// (ChargenTableReader.cs) but never consumed. The backdrop object
+ /// gets NO explicit position/orientation/scale anywhere in the function —
+ /// CPhysicsObj::makeObject(eax_32, 0, 1) (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.
+ ///
+ ///
+ ///
+ /// Both the Appearance page (gmCGAppearancePage) and the Summary
+ /// page (gmCGSummaryPage) call this SAME gmCG3DView::Update
+ /// function on their own gmCG3DView 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.
+ ///
+ ///
+ ///
+ /// .
+ /// Zero (unset/no environment authored for this heritage) returns null —
+ /// matches retail's own if (eax_32 != INVALID_DID.id) gate at
+ /// 0x004eed29, which skips makeObject/AddObject entirely
+ /// when the heritage has no environment Setup.
+ ///
+ 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(environmentSetupId);
+ if (setup is null)
+ return null;
+
+ var flattened = SetupMesh.Flatten(setup);
+ var drawable = new List(flattened.Count);
+ foreach (MeshRef part in flattened)
+ {
+ if (dats.Get(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,
+ };
+ }
+ }
+
/// No dat access — pure projection of the already-composed
/// ObjDesc's subpalettes, safe to call outside datLock.
private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance)
diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
index 91d58ee9..d643e2b1 100644
--- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
+++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
@@ -14,6 +14,15 @@ internal interface IChargenPreviewRenderer
{
void SetPreview(WorldEntity? entity);
+ ///
+ /// Sets or clears the environment backdrop entity drawn BEHIND the
+ /// preview (Campaign CC gate round 1 Batch D, GF-7/GF-14) — retail's
+ /// gmCG3DView::m_pbgObject. See
+ /// for the
+ /// decomp-cited placement.
+ ///
+ 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();
diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
index 97db5e00..22cb8ca5 100644
--- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
+++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
@@ -44,6 +44,20 @@ internal interface IPrivateEntityViewportCamera : ICamera
/// raw-GL WbDrawDispatcher into (through V10, §5.5.6) was deleted at
/// Campaign V slice V11: WbDrawDispatcher now records into the pass
/// this renderer publishes on both call sites the same way.
+///
+///
+/// Campaign CC gate round 1, Batch D (GF-7/GF-14). Retail's
+/// gmCG3DView::Update @0x004EE9D0 draws a SECOND private entity — a
+/// heritage-authored environment Setup (m_pbgObject) — behind the main
+/// one, in the SAME creature_mode_objects list. This renderer now
+/// supports that as an OPTIONAL second entity slot, reserved at construction
+/// via -shaped ctor param (see below) —
+/// paperdoll and creature-appraisal never pass one, so
+/// throws for them rather than silently doing nothing (the slot does not
+/// exist). See for
+/// the full decomp citation of the backdrop's placement (unposed, at the
+/// scene origin, added to the draw list BEFORE the main entity).
+///
///
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 _animatedIds;
private readonly string _diagnosticName;
- private readonly List
- _retiringMeshReferences = [];
+
+ private readonly EntitySlot _mainSlot;
+
+ /// Null for every renderer that never reserved a
+ /// backdropRenderId (paperdoll, creature-appraisal) — the backdrop
+ /// feature does not exist for them, not just "unused".
+ 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];
}
///
@@ -125,65 +152,27 @@ internal sealed class PrivateEntityViewportRenderer :
///
public bool TextureIsBottomUp => false;
- public void SetEntity(WorldEntity? entity)
+ public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
+
+ ///
+ /// Sets or clears the environment backdrop entity drawn BEHIND the main
+ /// entity — GF-7/GF-14's fix, retail's gmCG3DView::m_pbgObject. Only
+ /// valid on a renderer constructed with a backdropRenderId
+ /// ('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.
+ ///
+ 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);
}
///
@@ -193,7 +182,7 @@ internal sealed class PrivateEntityViewportRenderer :
///
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 drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
var entries =
new (uint, Vector3, Vector3, IReadOnlyList,
IReadOnlyDictionary?)[]
@@ -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);
}
+ ///
+ /// Pure helper assembling this frame's draw-entity list in retail's own
+ /// insertion order — gmCG3DView::Update adds the backdrop object to
+ /// creature_mode_objects BEFORE the main (player) object is
+ /// re-added (the player's own re-AddObject happens much later, at
+ /// ~0x004ef199, after the full clothing ObjDesc composes — see
+ /// '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
+ /// PrivateEntityViewportRendererDrawOrderTests without needing a
+ /// live GPU device or a constructed .
+ ///
+ internal static IReadOnlyList BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) =>
+ backdrop is not null && backdrop.MeshRefs.Count > 0
+ ? [backdrop, main]
+ : [main];
+
///
/// Both retail paperdoll and creature examination call
/// UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65)).
+ /// Byte-decoded confirmation (Batch D re-derivation): the SAME three
+ /// float32 constants (0x3e99999a/0x3ff33333/0x3F266666
+ /// = 0.3/1.9/0.65) appear verbatim at gmCG3DView::Update's own
+ /// SetLight call site (pseudo-C ~0x004eecd3-0x004eece3) — the
+ /// chargen preview uses the EXACT same light this method already ported,
+ /// not a different value.
///
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? 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()
+ ///
+ /// 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 's
+ /// pre-Batch-D body.
+ ///
+ private sealed class EntitySlot
{
- List? 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 _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? 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? 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);
+ }
}
}
}
diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs
index af7892f8..204b3740 100644
--- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs
@@ -204,6 +204,168 @@ public sealed class ChargenPreviewControllerTests
}
}
+ ///
+ /// 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
+ /// EnvironmentSetupId.
+ ///
+ [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);
+ }
+ }
+
+ /// 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.
+ [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(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);
+ }
+ }
+
+ ///
+ /// The backdrop swaps to the new heritage's own environment Setup on a
+ /// heritage change — the SAME gmCG3DView::Update gate
+ /// (m_bgSetupID.id != eax_32) that drives the main entity's own
+ /// re-dress.
+ ///
+ [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);
+ }
+ }
+
+ ///
+ /// Decomp-cited: the heritage's own environmentSetupID is a pure
+ /// function of heritage (HeritageGroup_CG), never gender or
+ /// appearance selection — an appearance-only Rebuild must not re-touch
+ /// the backdrop at all.
+ ///
+ [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; }
+ /// Batch D (GF-7/GF-14): last value passed to .
+ /// Null both before the first call AND after an explicit clear — tests
+ /// distinguish the two via .
+ 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++;
diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs
index 4e59b9a5..abebc743 100644
--- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewEntityBuilderTests.cs
@@ -275,4 +275,119 @@ public sealed class ChargenPreviewEntityBuilderTests
Assert.False(animator.IsZoomedIn);
Assert.NotEmpty(animator.Entity.MeshRefs);
}
+
+ ///
+ /// 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 EnvironmentSetupId was already parsed
+ /// () but never consumed before this fix.
+ ///
+ [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}");
+ }
+
+ /// Retail's own gate at 0x004eed29 (if (eax_32 != INVALID_DID.id))
+ /// skips creating a backdrop object entirely when the heritage authors no
+ /// environment Setup — id 0/unset must return null, not an empty entity.
+ [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);
+ }
+
+ ///
+ /// Live-DAT measurement (not assumed): every one of the 13 retail
+ /// heritages' EnvironmentSetupId 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.
+ ///
+ [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();
+ foreach (KeyValuePair 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.");
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererDrawOrderTests.cs b/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererDrawOrderTests.cs
new file mode 100644
index 00000000..ea07fae2
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererDrawOrderTests.cs
@@ -0,0 +1,83 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// Campaign CC gate round 1, Batch D (GF-7/GF-14): pins
+/// , the pure
+/// helper that decides which entities
+/// submits to WbDrawDispatcher. Exercised directly (no GPU device, no
+/// constructed WbDrawDispatcher) — the same interface-fake-first
+/// testing shape CreatureAppraisalPresentationTests already uses for
+/// this renderer family, since PrivateEntityViewportRenderer itself
+/// pulls in a live mesh-pipeline object graph too heavy to construct in a
+/// unit test.
+///
+///
+/// This is the paperdoll/creature-appraisal REGRESSION PIN the batch's test
+/// plan calls for: both renderers never configure a backdrop slot (see
+/// PaperdollViewportRenderer/CreatureAppraisalPresentation.cs —
+/// neither passes a backdropRenderId nor exposes SetBackdrop),
+/// so every one of their draws calls this helper with backdrop: null —
+/// exactly the first case below.
+///
+///
+public sealed class PrivateEntityViewportRendererDrawOrderTests
+{
+ private static WorldEntity Entity(uint id, IReadOnlyList 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 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 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 entities =
+ PrivateEntityViewportRenderer.BuildDrawEntities(emptyBackdrop, main);
+
+ Assert.Same(main, Assert.Single(entities));
+ }
+}