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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:05:23 +02:00

83 lines
3.2 KiB
C#

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