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; /// /// Campaign CC slice CC6b-MOUNT — installed-DAT gate for /// 's rebuild/render logic, using fake /// / /// implementations (no live GPU device needed — mirrors /// PaperdollFramePresenterTests's recording-fake pattern) against a /// REAL dat-backed / /// so ChargenAppearanceFactory.TryCompose and /// ChargenPreviewEntityBuilder.TryBuildAnimated actually run. /// /// /// Fix round F13: every case uses /// (shared with /// CharacterCreationLiveDatTests/CharacterManagementLiveDatTests) /// instead of plain [Fact]. Before this fix, a bare [Fact] plus /// 's own if (!TryOpen(...)) return; guard made /// all six cases pass SILENTLY with zero assertions run whenever /// ACDREAM_DAT_DIR 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". /// /// 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); } } /// /// Decomp-cited (gmCGAppearancePage::Update's two confirmed direct /// call sites — InitializePage and the gender-button handlers — /// vs the narrower SetSelection/SetColor/SetShade /// 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. /// [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); } } /// /// 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() { 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)); /// /// Fix round F13: already gates /// every case above this call, so datDir is null 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 /// client_portal.dat the attribute's own lighter check missed) /// rather than a silent no-assertions pass. /// 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; } /// 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++; 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; } }