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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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