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>
478 lines
20 KiB
C#
478 lines
20 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Tests.UI.Layout; // InstalledDatFactAttribute (fix round F13)
|
|
using AcDream.Content;
|
|
using AcDream.Content.CharGen;
|
|
using AcDream.Content.Vfx;
|
|
using AcDream.Core.CharGen;
|
|
using AcDream.Core.Physics.Motion;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.Options;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
/// <summary>
|
|
/// Campaign CC slice CC6b-MOUNT — installed-DAT gate for
|
|
/// <see cref="ChargenPreviewController"/>'s rebuild/render logic, using fake
|
|
/// <see cref="IChargenPreviewRenderer"/>/<see cref="IChargenPreviewFrameView"/>
|
|
/// implementations (no live GPU device needed — mirrors
|
|
/// <c>PaperdollFramePresenterTests</c>'s recording-fake pattern) against a
|
|
/// REAL dat-backed <see cref="ChargenOptions"/>/<see cref="ChargenAppearanceCatalog"/>
|
|
/// so <c>ChargenAppearanceFactory.TryCompose</c> and
|
|
/// <c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> actually run.
|
|
///
|
|
/// <para>
|
|
/// Fix round F13: every case uses
|
|
/// <see cref="InstalledDatFactAttribute"/> (shared with
|
|
/// <c>CharacterCreationLiveDatTests</c>/<c>CharacterManagementLiveDatTests</c>)
|
|
/// instead of plain <c>[Fact]</c>. Before this fix, a bare <c>[Fact]</c> plus
|
|
/// <see cref="TryOpen"/>'s own <c>if (!TryOpen(...)) return;</c> guard made
|
|
/// all six cases pass SILENTLY with zero assertions run whenever
|
|
/// <c>ACDREAM_DAT_DIR</c> was unavailable — indistinguishable in the test
|
|
/// runner's summary from an actual passing run. The attribute now reports
|
|
/// those runs as Skipped so the counts show "ran" separately from "no-op'd".
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ChargenPreviewControllerTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public ChargenPreviewControllerTests(ITestOutputHelper output) => _out = output;
|
|
|
|
private const uint AluvianId = 1u;
|
|
private const uint GearknightId = 6u;
|
|
|
|
[InstalledDatFact]
|
|
public void Rebuild_SameSelectionTwice_IsANoOpSecondTime()
|
|
{
|
|
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 selection = DefaultSelection(options, AluvianId, 1);
|
|
Assert.True(controller.Rebuild(options, AluvianId, 1, selection));
|
|
Assert.True(controller.Rebuild(options, AluvianId, 1, selection));
|
|
|
|
Assert.Equal(1, renderer.SetPreviewCallCount);
|
|
}
|
|
}
|
|
|
|
[InstalledDatFact]
|
|
public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye()
|
|
{
|
|
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 camera = new ChargenPreviewCamera();
|
|
var controller = new ChargenPreviewController(
|
|
renderer, camera, view,
|
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
|
|
|
Assert.True(controller.Rebuild(
|
|
options, AluvianId, 1, DefaultSelection(options, AluvianId, 1)));
|
|
// Poke the SAME camera instance the controller shares (this is
|
|
// exactly the injection seam ChargenPreviewController's own doc
|
|
// comment describes) — simulates the camera having drifted away
|
|
// from the heritage default (e.g. mid zoom-out tween).
|
|
camera.Eye = new Vector3(0f, -99f, 99f);
|
|
|
|
if (!options.TryGetHeritage(GearknightId, out ChargenHeritageOptions? gearknight)
|
|
|| gearknight!.GendersByKey.Count == 0)
|
|
{
|
|
_out.WriteLine("SKIP: installed dat has no Gearknight gender to switch to.");
|
|
return;
|
|
}
|
|
int gearknightGender = gearknight.GendersByKey.Keys.First();
|
|
Assert.True(controller.Rebuild(
|
|
options, GearknightId, gearknightGender,
|
|
DefaultSelection(options, GearknightId, gearknightGender)));
|
|
|
|
Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(GearknightId), controller.CameraEye);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decomp-cited (<c>gmCGAppearancePage::Update</c>'s two confirmed direct
|
|
/// call sites — <c>InitializePage</c> and the gender-button handlers —
|
|
/// vs the narrower <c>SetSelection</c>/<c>SetColor</c>/<c>SetShade</c>
|
|
/// every spin/color/shade change goes through instead): a rebuild that
|
|
/// changes ONLY the appearance selection (same heritage, same gender)
|
|
/// must NOT snap the camera back to the heritage default.
|
|
/// </summary>
|
|
[InstalledDatFact]
|
|
public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched()
|
|
{
|
|
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 camera = new ChargenPreviewCamera();
|
|
var controller = new ChargenPreviewController(
|
|
renderer, camera, view,
|
|
adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object());
|
|
|
|
ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1);
|
|
Assert.True(controller.Rebuild(options, AluvianId, 1, first));
|
|
var pokedEye = new Vector3(0f, -99f, 99f);
|
|
camera.Eye = pokedEye;
|
|
|
|
ChargenAppearanceSelection second = first with { SkinShade = 0.9 };
|
|
Assert.True(controller.Rebuild(options, AluvianId, 1, second));
|
|
|
|
Assert.Equal(pokedEye, controller.CameraEye);
|
|
}
|
|
}
|
|
|
|
[InstalledDatFact]
|
|
public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange()
|
|
{
|
|
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));
|
|
controller.ZoomIn();
|
|
Assert.True(controller.IsZoomedIn);
|
|
|
|
// A DIFFERENT selection, same heritage/gender — retail's
|
|
// gmCGAppearancePage::Update only resets the camera POSITION on
|
|
// heritage/gender change; m_bZoomedIn is untouched by spin/color/
|
|
// shade edits.
|
|
ChargenAppearanceSelection second = first with { SkinShade = 0.9 };
|
|
Assert.True(controller.Rebuild(options, AluvianId, 1, second));
|
|
|
|
Assert.True(controller.IsZoomedIn);
|
|
}
|
|
}
|
|
|
|
[InstalledDatFact]
|
|
public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees()
|
|
{
|
|
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());
|
|
|
|
Assert.True(controller.Rebuild(
|
|
options, AluvianId, 1, DefaultSelection(options, AluvianId, 1)));
|
|
Assert.NotNull(renderer.LastEntity);
|
|
|
|
controller.Render();
|
|
|
|
Quaternion expected = MoveToMath.SetHeading(
|
|
Quaternion.Identity, ChargenPreviewRotationController.RetailDefaultHeadingDegrees);
|
|
Quaternion actual = renderer.LastEntity!.Rotation;
|
|
Assert.Equal(expected.X, actual.X, 4);
|
|
Assert.Equal(expected.Y, actual.Y, 4);
|
|
Assert.Equal(expected.Z, actual.Z, 4);
|
|
Assert.Equal(expected.W, actual.W, 4);
|
|
}
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
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 { Visible = false };
|
|
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)));
|
|
|
|
controller.Render();
|
|
|
|
Assert.Equal(0, renderer.RenderCallCount);
|
|
Assert.Null(view.LastTextureHandle);
|
|
}
|
|
}
|
|
|
|
private static ChargenAppearanceSelection DefaultSelection(
|
|
ChargenOptions options, uint heritageId, int genderKey)
|
|
{
|
|
Assert.True(options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage));
|
|
Assert.True(heritage!.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender));
|
|
return ChargenAppearanceSelection.Default with
|
|
{
|
|
HairStyle = gender!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset,
|
|
SkinShade = 0.5,
|
|
};
|
|
}
|
|
|
|
private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) =>
|
|
(ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats));
|
|
|
|
/// <summary>
|
|
/// Fix round F13: <see cref="InstalledDatFactAttribute"/> already gates
|
|
/// every case above this call, so <c>datDir is null</c> should not be
|
|
/// reachable in practice once a case actually runs — this stays as
|
|
/// defense-in-depth (a DAT directory that exists but a corrupt/renamed
|
|
/// <c>client_portal.dat</c> the attribute's own lighter check missed)
|
|
/// rather than a silent no-assertions pass.
|
|
/// </summary>
|
|
private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)
|
|
{
|
|
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
|
if (datDir is null)
|
|
{
|
|
_out.WriteLine("SKIP: dats unavailable");
|
|
dats = null;
|
|
adapter = null;
|
|
return false;
|
|
}
|
|
dats = new DatCollection(datDir, DatAccessType.Read);
|
|
adapter = new DatCollectionAdapter(dats);
|
|
return true;
|
|
}
|
|
|
|
private sealed class FakeChargenRenderer : IChargenPreviewRenderer
|
|
{
|
|
public WorldEntity? LastEntity { get; private set; }
|
|
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++;
|
|
return 42u;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeChargenView : IChargenPreviewFrameView
|
|
{
|
|
public bool Visible { get; set; } = true;
|
|
public int Width { get; set; } = 128;
|
|
public int Height { get; set; } = 128;
|
|
public uint? LastTextureHandle { get; private set; }
|
|
|
|
public bool TryGetVisibleSize(out int width, out int height)
|
|
{
|
|
width = Width;
|
|
height = Height;
|
|
return Visible;
|
|
}
|
|
|
|
public void SetTextureHandle(uint textureHandle) => LastTextureHandle = textureHandle;
|
|
}
|
|
}
|