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