refactor(rendering): delete superseded visibility probes
Delete the callerless portal-BFS research graph and spent renderer probe families while retaining the production RetailFrameWalk path, terrain diagnostics, membership invariant, and walk transcript. Mutation evidence (all restored): 1. Restored PortalVisibilityBuilder type -> AppAssembly_ContainsNoSupersededPortalGraphTypes first failed Assert.Empty with AcDream.App.Rendering.PortalVisibilityBuilder. 2. Restored ACDREAM_PROBE_FACILITY_STAIRS -> ProductionSource_ContainsNoDeletedRendererProbe_AndRetainsWalkTranscriptProof first failed Assert.Empty on RenderingDiagnostics.cs. 3. Added a second RetailFrameWalk field -> WalkFrameOwners_AreUnique first failed Assert.Single with _frameWalk and _mutatedSecondFrameWalk. 4. Added OrderBy to OrderedStream -> OrderedWalkStream_HasNoCrossStreamReorder first failed Assert.DoesNotContain on OrderBy(. 5. Added IDatReaderWriter parameter -> FrameTimeWalkOwners_HaveNoRawDatDependency first failed Assert.Empty on RetailFrameWalk.MutatedRawDatParameter.
This commit is contained in:
parent
b77989c323
commit
bf53e2ad6e
82 changed files with 451 additions and 10108 deletions
|
|
@ -325,11 +325,6 @@ public sealed class HostInputCameraCompositionTests
|
|||
GpuFrameFlightController? frameFlights,
|
||||
IGpuDevice device) => frameFlights!;
|
||||
|
||||
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
|
||||
GameWindowGraphics graphics,
|
||||
IRenderFrameDiagnosticLog log) =>
|
||||
new(new GlStateReader(), log);
|
||||
|
||||
public SilkKeyboardSource CreateKeyboardSource(
|
||||
IKeyboard keyboard,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
|
|
@ -498,12 +493,6 @@ public sealed class HostInputCameraCompositionTests
|
|||
public void Delete(nint fence) { }
|
||||
}
|
||||
|
||||
private sealed class GlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
||||
private sealed class DiagnosticLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public void WriteLine(string message) { }
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ namespace AcDream.App.Tests.Rendering;
|
|||
/// each class's <c>finally</c> sufficient.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Three distinct globals are covered, and every one of them is a shared
|
||||
/// <para>The shared globals below are covered, and every one of them is an
|
||||
/// edge between at least two classes in this collection:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>AcDream.Core.Rendering.CameraDiagnostics</c> —
|
||||
|
|
@ -28,15 +28,12 @@ namespace AcDream.App.Tests.Rendering;
|
|||
/// <c>RetailChaseCamera.Update</c>, <c>CameraController.Active</c>,
|
||||
/// <c>CameraFrameController</c>, <c>WorldRenderFrameBuilder</c> and
|
||||
/// <c>MouseLookController</c>.</description></item>
|
||||
/// <item><description><c>AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled</c>
|
||||
/// — written by <c>CornerFloodReplayTests</c> and
|
||||
/// <c>Issue181WallPressEquilibriumTests</c>.</description></item>
|
||||
/// <item><description><c>AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled</c>
|
||||
/// (Campaign OVERHAUL S3 chunk 1) — written by
|
||||
/// <c>WalkFrameDriverTests</c>' transcript-emitter tests
|
||||
/// (<c>WalkFrameDriverTranscriptTests.cs</c>).</description></item>
|
||||
/// <item><description><c>System.Console.Out</c> — redirected via
|
||||
/// <c>Console.SetOut</c> by those same classes to capture probe output.
|
||||
/// <c>Console.SetOut</c> by the transcript tests to capture output.
|
||||
/// Interleaved redirection can restore a DISPOSED <c>StringWriter</c> as the
|
||||
/// process-wide <c>Console.Out</c>, which then throws in unrelated
|
||||
/// tests.</description></item>
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ public sealed class ChargenPreviewControllerTests
|
|||
/// </summary>
|
||||
private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dats unavailable");
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuild_AluvianMaleDefaultSelection_ProducesANonEmptyStaticPoseEntity()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -87,7 +87,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
ChargenPreviewEntityBuilder.PreviewBackdropRenderId,
|
||||
ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId);
|
||||
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -121,7 +121,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuild_UnknownSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -145,7 +145,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuild_OlthoiHeritage_ResolvesADifferentRestPoseDidThanStandardHeritages()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -186,7 +186,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildAnimated_AluvianMaleDefaultSelection_ResolvesARealIdleCycle()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -232,7 +232,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildAnimated_UnknownSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -274,7 +274,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[InlineData(0x10000013u)] // OlthoiAcid's shared idle/rest enum key.
|
||||
public void OlthoiFamily_SharedIdleRestEnumKey_ResolvesToARealInstalledDid(uint sharedEnumKey)
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -297,7 +297,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildAnimated_OlthoiHeritage_ResolvesARealIdleAnimationToo()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -337,7 +337,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_AluvianHeritage_ResolvesANonEmptyMesh()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -376,7 +376,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_ExplicitRenderId_StampsThatIdOnTheEntity()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -405,7 +405,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_UnsetEnvironmentSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -419,7 +419,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_UnknownSetupId_ReturnsNull()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
@ -440,7 +440,7 @@ public sealed class ChargenPreviewEntityBuilderTests
|
|||
[Fact]
|
||||
public void TryBuildBackdrop_AllThirteenHeritages_ResolveOrAreReportedByName()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
|
@ -7,30 +6,12 @@ namespace AcDream.App.Tests.Rendering;
|
|||
|
||||
public class ClipFrameAssemblerTests
|
||||
{
|
||||
private static ViewPolygon Square(float cx, float cy, float half) => new(new[]
|
||||
{
|
||||
new Vector2(cx - half, cy - half),
|
||||
new Vector2(cx + half, cy - half),
|
||||
new Vector2(cx + half, cy + half),
|
||||
new Vector2(cx - half, cy + half),
|
||||
});
|
||||
|
||||
private static CellView ViewOf(params ViewPolygon[] polys)
|
||||
{
|
||||
var view = new CellView();
|
||||
foreach (var p in polys)
|
||||
view.Add(p);
|
||||
return view;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginWalkFrame_OutdoorRoot_SeedsOneFullScreenDefaultView()
|
||||
{
|
||||
using ClipFrame frame = ClipFrame.NoClip();
|
||||
|
||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
|
||||
frame,
|
||||
outdoorRoot: true);
|
||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true);
|
||||
|
||||
ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices);
|
||||
Assert.Equal(0, slice.Slot);
|
||||
|
|
@ -45,9 +26,7 @@ public class ClipFrameAssemblerTests
|
|||
public void BeginWalkFrame_InteriorRoot_ResetsReusedOutdoorAssemblyToEmpty()
|
||||
{
|
||||
using ClipFrame frame = ClipFrame.NoClip();
|
||||
ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame(
|
||||
frame,
|
||||
outdoorRoot: true);
|
||||
ClipFrameAssembly reuse = ClipFrameAssembler.BeginWalkFrame(frame, outdoorRoot: true);
|
||||
|
||||
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
|
||||
frame,
|
||||
|
|
@ -62,270 +41,4 @@ public class ClipFrameAssemblerTests
|
|||
Assert.Equal(0, assembly.ScissorFallbacks);
|
||||
Assert.Equal(1, frame.SlotCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
const uint cellB = 0xA9B40101;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(-0.3f, 0f, 0.3f));
|
||||
pv.CellViews[cellB] = ViewOf(Square(0.3f, 0f, 0.2f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OrderedVisibleCells.Add(cellB);
|
||||
pv.OutsideView.Add(Square(0f, 0.5f, 0.25f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice
|
||||
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||
Assert.Contains(cellB, asm.CellIdToSlot.Keys);
|
||||
Assert.NotEqual(0, asm.CellIdToSlot[cellA]);
|
||||
Assert.NotEqual(0, asm.CellIdToSlot[cellB]);
|
||||
Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellB]);
|
||||
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
Assert.NotEqual(0, asm.OutdoorSlot);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot);
|
||||
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
const uint cellB = 0xA9B40101;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.CellViews[cellB] = new CellView();
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OrderedVisibleCells.Add(cellB);
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.Equal(2, asm.Frame.SlotCount);
|
||||
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||
Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys);
|
||||
Assert.False(asm.OutdoorVisible);
|
||||
Assert.Empty(asm.OutsideViewSlices);
|
||||
Assert.Equal(0, asm.OutsidePlaneCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutsideViewMultiPolygon_PreservesRetailSlices()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f));
|
||||
pv.OutsideView.Add(Square(0.5f, 0f, 0.1f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
Assert.NotEqual(0, asm.OutdoorSlot);
|
||||
Assert.Equal(2, asm.OutsideViewSlices.Length);
|
||||
Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot);
|
||||
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellMultiPolygonView_PreservesRetailViewSlices()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(
|
||||
Square(-0.4f, 0f, 0.1f),
|
||||
Square(0.4f, 0f, 0.1f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OutsideView.Add(Square(0f, 0f, 0.3f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.CellIdToSlot[cellA] > 0);
|
||||
Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length);
|
||||
Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length);
|
||||
Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
|
||||
{
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
|
||||
var expected = new Vector4(
|
||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_OutsideViewMultiPolygon_PreservesSlicesAndUnionAabb()
|
||||
{
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f));
|
||||
pv.OutsideView.Add(Square(0.6f, 0f, 0.15f));
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Equal(2, asm.OutsideViewSlices.Length);
|
||||
|
||||
var expected = new Vector4(
|
||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero()
|
||||
{
|
||||
const uint cellA = 0xA9B40100;
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.False(asm.HasOutsideView);
|
||||
Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies()
|
||||
{
|
||||
var frame = ClipFrame.NoClip();
|
||||
|
||||
var pv1 = new PortalVisibilityFrame();
|
||||
pv1.CellViews[0xA9B40100] = ViewOf(Square(-0.3f, 0f, 0.2f));
|
||||
pv1.CellViews[0xA9B40101] = ViewOf(Square(0.3f, 0f, 0.2f));
|
||||
pv1.OrderedVisibleCells.Add(0xA9B40100);
|
||||
pv1.OrderedVisibleCells.Add(0xA9B40101);
|
||||
pv1.OutsideView.Add(Square(0f, 0.4f, 0.2f));
|
||||
var asm1 = ClipFrameAssembler.Assemble(frame, pv1);
|
||||
Assert.Equal(4, asm1.Frame.SlotCount);
|
||||
|
||||
var pv2 = new PortalVisibilityFrame();
|
||||
pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f));
|
||||
pv2.OrderedVisibleCells.Add(0xA9B40200);
|
||||
var asm2 = ClipFrameAssembler.Assemble(frame, pv2);
|
||||
|
||||
Assert.Equal(2, asm2.Frame.SlotCount);
|
||||
Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys);
|
||||
Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys);
|
||||
Assert.False(asm2.OutdoorVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_ReusedAssembly_ClearsStateAndReusesExactLengthArrays()
|
||||
{
|
||||
const uint firstCell = 0xA9B40100;
|
||||
const uint secondCell = 0xA9B40200;
|
||||
var frame = ClipFrame.NoClip();
|
||||
var reuse = new ClipFrameAssembly();
|
||||
|
||||
var first = new PortalVisibilityFrame();
|
||||
first.CellViews[firstCell] = ViewOf(
|
||||
Square(-0.4f, 0f, 0.15f),
|
||||
Square(0.4f, 0f, 0.15f));
|
||||
first.OrderedVisibleCells.Add(firstCell);
|
||||
first.OutsideView.Add(Square(0f, 0.5f, 0.2f));
|
||||
ClipFrameAssembly firstResult = ClipFrameAssembler.Assemble(frame, first, reuse);
|
||||
Assert.Same(reuse, firstResult);
|
||||
int sliceAllocations = reuse.SliceArrayAllocationCount;
|
||||
int slotAllocations = reuse.SlotArrayAllocationCount;
|
||||
|
||||
var second = new PortalVisibilityFrame();
|
||||
second.CellViews[secondCell] = ViewOf(
|
||||
Square(-0.4f, 0f, 0.15f),
|
||||
Square(0.4f, 0f, 0.15f));
|
||||
second.OrderedVisibleCells.Add(secondCell);
|
||||
second.OutsideView.Add(Square(0f, 0.5f, 0.2f));
|
||||
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
ClipFrameAssembly actual = ClipFrameAssembler.Assemble(frame, second, reuse);
|
||||
Assert.Same(reuse, actual);
|
||||
Assert.DoesNotContain(firstCell, actual.CellIdToViewSlices.Keys);
|
||||
Assert.Contains(secondCell, actual.CellIdToViewSlices.Keys);
|
||||
Assert.Equal(2, actual.CellIdToViewSlices[secondCell].Length);
|
||||
Assert.Equal(2, actual.CellIdToViewSlots[secondCell].Length);
|
||||
Assert.Single(actual.OutsideViewSlices);
|
||||
Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount);
|
||||
Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount);
|
||||
}
|
||||
|
||||
var empty = new PortalVisibilityFrame();
|
||||
ClipFrameAssembly cleared = ClipFrameAssembler.Assemble(frame, empty, reuse);
|
||||
Assert.Empty(cleared.CellIdToSlot);
|
||||
Assert.Empty(cleared.CellIdToViewSlots);
|
||||
Assert.Empty(cleared.CellIdToViewSlices);
|
||||
Assert.Empty(cleared.PerCellPlaneCounts);
|
||||
Assert.Empty(cleared.OutsideViewSlices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_ReusedAssembly_ChangingCardinalityStaysBoundedThenReachesSteadyState()
|
||||
{
|
||||
const uint cellId = 0xA9B40100;
|
||||
var frame = ClipFrame.NoClip();
|
||||
var reuse = new ClipFrameAssembly();
|
||||
|
||||
static PortalVisibilityFrame FrameWithSlices(uint id, int count)
|
||||
{
|
||||
var portalFrame = new PortalVisibilityFrame();
|
||||
var view = new CellView();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
// The assembler consumes the retail view_poly list directly. Distinct placement is
|
||||
// irrelevant to this cache-cardinality stress; avoid CellView dedup collapsing it.
|
||||
view.Polygons.Add(Square(i * 0.001f, 0f, 0.0004f));
|
||||
}
|
||||
portalFrame.CellViews[id] = view;
|
||||
portalFrame.OrderedVisibleCells.Add(id);
|
||||
return portalFrame;
|
||||
}
|
||||
|
||||
for (int count = 1; count <= 180; count++)
|
||||
ClipFrameAssembler.Assemble(frame, FrameWithSlices(cellId, count), reuse);
|
||||
|
||||
// Flush the final live arrays into the cache too.
|
||||
ClipFrameAssembler.Assemble(frame, new PortalVisibilityFrame(), reuse);
|
||||
Assert.InRange(reuse.RetainedSliceItems, 0, ClipFrameAssembly.MaxRetainedSliceItems);
|
||||
Assert.InRange(reuse.RetainedSlotItems, 0, ClipFrameAssembly.MaxRetainedSlotItems);
|
||||
Assert.InRange(reuse.RetainedSliceArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool);
|
||||
Assert.InRange(reuse.RetainedSlotArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool);
|
||||
|
||||
PortalVisibilityFrame steady = FrameWithSlices(cellId, 73);
|
||||
ClipFrameAssembler.Assemble(frame, steady, reuse);
|
||||
int sliceAllocations = reuse.SliceArrayAllocationCount;
|
||||
int slotAllocations = reuse.SlotArrayAllocationCount;
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
ClipFrameAssembler.Assemble(frame, steady, reuse);
|
||||
Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount);
|
||||
Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,685 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// §4 corner-press flood replay (2026-06-10). The §2b camera-collision hypothesis is
|
||||
/// REFUTED (CameraCornerSealReplayTests, commit b21bb28): the eye legitimately enters
|
||||
/// the neighbour room through the 0171↔0173↔0172 doorway chain. The user still sees
|
||||
/// BACKGROUND at certain angles — so the defect is in the FLOOD/CLIP output for those
|
||||
/// eye positions. This harness drives the REAL <see cref="PortalVisibilityBuilder.Build"/>
|
||||
/// over the REAL Holtburg building cells (dat-loaded, mirroring EnvCellLandblockBuildBuilder)
|
||||
/// along the captured corner eye path, and prints which cells the flood keeps/drops per
|
||||
/// step. The player's room (0172) dropping while the player is visible on screen is the
|
||||
/// background defect; the step where it happens pins the mechanism.
|
||||
///
|
||||
/// Geometry (corner-seal-capture.log + the b21bb28 room map): all cells share origin
|
||||
/// (161.93, 7.50, 94.00), local = R180z·(world−origin). The 0171-side doorway plane is
|
||||
/// local x=4.10 (world x≈157.83), the 0172-side plane local x=3.90 (world x≈158.03);
|
||||
/// the opening is 1.9 m wide, full height (z 94..96.5). Player dwell position during the
|
||||
/// corner press: (159.94, 7.70, 94.00) in 0172; captured eyes hover near (157.4..157.5,
|
||||
/// 7.91, 96.25) — inside 0171, 0.35 m past the doorway plane, near the ceiling.
|
||||
/// </summary>
|
||||
[Collection(CameraDiagnosticsCollection.Name)]
|
||||
public class CornerFloodReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public CornerFloodReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
internal const uint Landblock = 0xA9B40000u;
|
||||
private const uint EnvironmentFilePrefix = 0x0D000000u;
|
||||
|
||||
internal static string? ResolveDatDir()
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
return fromEnv;
|
||||
var def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dat → LoadedCell, mirroring EnvCellLandblockBuildBuilder.
|
||||
/// field-for-field: portals (with OtherPortalId back-link), clip planes from the
|
||||
/// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in
|
||||
/// cell-local space, local AABB, world transform from EnvCell.Position.
|
||||
/// </summary>
|
||||
internal static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||
=> LoadCell(dats, cellId, Vector3.Zero);
|
||||
|
||||
/// <summary>worldOffset: block offset for multi-landblock fixtures (dat cell
|
||||
/// positions are landblock-local; a neighbour block needs ±192 per axis).</summary>
|
||||
internal static LoadedCell LoadCell(DatCollection dats, uint cellId, Vector3 worldOffset)
|
||||
{
|
||||
var envCell = dats.Get<DatEnvCell>(cellId)
|
||||
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
||||
var environment = dats.Get<DatEnvironment>(EnvironmentFilePrefix | envCell.EnvironmentId)
|
||||
?? throw new InvalidOperationException($"Environment 0x{envCell.EnvironmentId:X8} not found");
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct) || cellStruct is null)
|
||||
throw new InvalidOperationException($"CellStruct {envCell.CellStructure} missing");
|
||||
|
||||
var cellTransform =
|
||||
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
Matrix4x4.CreateTranslation(envCell.Position.Origin + worldOffset);
|
||||
Matrix4x4.Invert(cellTransform, out var inverse);
|
||||
|
||||
var boundsMin = new Vector3(float.MaxValue);
|
||||
var boundsMax = new Vector3(float.MinValue);
|
||||
foreach (var kvp in cellStruct.VertexArray.Vertices)
|
||||
{
|
||||
var v = kvp.Value;
|
||||
var pos = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
|
||||
boundsMin = Vector3.Min(boundsMin, pos);
|
||||
boundsMax = Vector3.Max(boundsMax, pos);
|
||||
}
|
||||
if (boundsMin.X == float.MaxValue) { boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; }
|
||||
|
||||
var portals = new List<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<Vector3[]>();
|
||||
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
portals.Add(new CellPortalInfo(
|
||||
portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags, portal.OtherPortalId));
|
||||
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||||
&& poly.VertexIds.Count >= 3
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
|
||||
{
|
||||
var p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
|
||||
var p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
|
||||
var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||||
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
||||
float d = -Vector3.Dot(normal, p0);
|
||||
// InsideSide from the dat PortalSide bit — mirrors the production fix
|
||||
// (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics
|
||||
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
|
||||
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
|
||||
clipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
clipPlanes.Add(default);
|
||||
}
|
||||
|
||||
Vector3[] polyVerts = Array.Empty<Vector3>();
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
|
||||
&& portalPoly.VertexIds.Count >= 3)
|
||||
{
|
||||
polyVerts = new Vector3[portalPoly.VertexIds.Count];
|
||||
bool allResolved = true;
|
||||
for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||||
(ushort)portalPoly.VertexIds[vi], out var pv))
|
||||
polyVerts[vi] = new Vector3(pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
|
||||
else { allResolved = false; break; }
|
||||
}
|
||||
if (!allResolved) polyVerts = Array.Empty<Vector3>();
|
||||
}
|
||||
portalPolygons.Add(polyVerts);
|
||||
}
|
||||
|
||||
uint lbPrefix = cellId & 0xFFFF0000u;
|
||||
var visibleCells = new List<uint>();
|
||||
if (envCell.VisibleCells is not null)
|
||||
foreach (var lowId in envCell.VisibleCells)
|
||||
visibleCells.Add(lbPrefix | lowId);
|
||||
|
||||
return new LoadedCell
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldPosition = envCell.Position.Origin + worldOffset,
|
||||
WorldTransform = cellTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
LocalBoundsMin = boundsMin,
|
||||
LocalBoundsMax = boundsMax,
|
||||
Portals = portals,
|
||||
ClipPlanes = clipPlanes,
|
||||
PortalPolygons = portalPolygons,
|
||||
VisibleCells = visibleCells,
|
||||
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
|
||||
};
|
||||
}
|
||||
|
||||
internal static Dictionary<uint, LoadedCell> LoadBuilding(DatCollection dats)
|
||||
{
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
for (uint low = 0x016Fu; low <= 0x0175u; low++)
|
||||
{
|
||||
uint id = Landblock | low;
|
||||
cells[id] = LoadCell(dats, id);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// Production projection: ChaseCamera/FlyCamera use FovY ~1.2, near 1, far 5000,
|
||||
// 1280x720 (the capture's viewport). The flood's clip is near-independent
|
||||
// (PortalProjection header), so near/far exactness is not load-bearing.
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
// World-x of the doorway planes (local x=4.10 / 3.90 under the R180z transform
|
||||
// about origin x=161.93).
|
||||
private const float Plane0171X = 161.93f - 4.10f; // ≈157.83
|
||||
private const float Plane0172X = 161.93f - 3.90f; // ≈158.03
|
||||
|
||||
private static uint RootCellFor(float eyeX) =>
|
||||
eyeX > Plane0172X ? (Landblock | 0x0172u)
|
||||
: eyeX > Plane0171X ? (Landblock | 0x0173u)
|
||||
: (Landblock | 0x0171u);
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic: full [pv-trace] decision log for one GOOD step (47) vs one GLITCH
|
||||
/// step (48) of the sweep below — 2 cm apart, root 0171 both, yet 0172 (and its
|
||||
/// downstream 016F + outside) vanish from the flood at 48. The trace diff pins the
|
||||
/// exact gate that flips.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_TraceGoodVsGlitchStep()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
foreach (int i in new[] { 47, 48 })
|
||||
{
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
uint root = RootCellFor(ex);
|
||||
|
||||
var sw = new StringWriter();
|
||||
var prev = Console.Out;
|
||||
PortalVisibilityFrame frame;
|
||||
try
|
||||
{
|
||||
Console.SetOut(sw);
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = true;
|
||||
frame = PortalVisibilityBuilder.Build(cells[root], eye, lookup, ViewProjFor(eye, pivot));
|
||||
}
|
||||
finally
|
||||
{
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled = false;
|
||||
Console.SetOut(prev);
|
||||
}
|
||||
|
||||
_out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########");
|
||||
foreach (var line in sw.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
_out.WriteLine(line.TrimEnd());
|
||||
|
||||
// Per-hop region vertices: where does the sliver come from?
|
||||
foreach (uint low in new uint[] { 0x0171u, 0x0173u, 0x0172u })
|
||||
{
|
||||
if (!frame.CellViews.TryGetValue(Landblock | low, out var view))
|
||||
{
|
||||
_out.WriteLine($" view 0x{low:X4}: (absent)");
|
||||
continue;
|
||||
}
|
||||
foreach (var p in view.Polygons)
|
||||
{
|
||||
string verts = "";
|
||||
foreach (var v in p.Vertices)
|
||||
verts += System.FormattableString.Invariant($" ({v.X:F5},{v.Y:F5})");
|
||||
_out.WriteLine($" view 0x{low:X4} [{p.Vertices.Length}]:{verts}");
|
||||
}
|
||||
if (view.Polygons.Count == 0)
|
||||
_out.WriteLine($" view 0x{low:X4}: 0 polygons (rejected by Add)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §4 conformance gate (2026-06-08 handoff §7's pre-gate, realized 2026-06-10):
|
||||
/// a smooth monotonic eye moving through/near a doorway must produce a STABLE,
|
||||
/// monotone flood — the full cell chain present on every step, the outside view
|
||||
/// always reached, and the player-room region never collapsing nor oscillating.
|
||||
/// Before the homogeneous-reciprocal fix this failed on ~10 of 61 steps (room
|
||||
/// 0172 vanished entirely or collapsed to a sub-pixel sliver — the corner /
|
||||
/// transition background strobe). Guards the ApplyReciprocalClip pipeline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void CornerSweep_FloodIsCompleteAndMonotone()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
// W=0 port (2026-06-11) — the criterion is retail's, not the rescue's:
|
||||
// - Cells BEHIND the camera are NOT required (retail polyClipFinish clips an
|
||||
// all-behind portal to empty; the rescue used to flood them anyway). The eye
|
||||
// looks AT the player throughout, so the doorway chain is behind the camera
|
||||
// until the eye recedes through it: require 0173 from root=0173 on, 0171
|
||||
// from root=0171 on. 0172 (the looked-at room with the player) is required
|
||||
// at EVERY step — that is THE user-visible §4 invariant.
|
||||
// - The two KNIFE-EDGE steps (eye exactly ON a doorway plane, the sweep grid
|
||||
// lands on the plane constants) propagate retail's zero-area degenerate view:
|
||||
// the chain stays in the draw list (cells draw whole) but the 0172 region is
|
||||
// legitimately zero-area and the onward flood (016F/outside) legitimately
|
||||
// dies for that frame — exempt those assertions there.
|
||||
// - Monotone shrink holds WITHIN a root regime; the root flip is a legitimate
|
||||
// discontinuity (FullScreen root view -> portal-clipped view).
|
||||
var failures = new List<string>();
|
||||
float prevArea = float.MaxValue;
|
||||
uint prevRoot = 0;
|
||||
for (int i = 0; i <= 60; i++)
|
||||
{
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
uint root = RootCellFor(ex);
|
||||
bool knifeEdge = MathF.Abs(ex - Plane0172X) < 0.005f || MathF.Abs(ex - Plane0171X) < 0.005f;
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[root], eye, lookup, ViewProjFor(eye, pivot));
|
||||
|
||||
// Knife-edge steps: the eye sits EXACTLY on a cell-boundary plane, so the
|
||||
// root pick itself is ambiguous (production BSP picks either side; a damped
|
||||
// float eye never lands exactly on the plane). The portal whose plane
|
||||
// contains the eye is ~perpendicular to the gaze here — genuinely
|
||||
// off-screen, retail's screen-bounded clip floods nothing through it from
|
||||
// the far-side root. Require only the root; the neighbouring steps (±2 cm,
|
||||
// where the real strobe class lived) carry the full criterion.
|
||||
var required = new List<uint>();
|
||||
if (knifeEdge)
|
||||
{
|
||||
required.Add(root & 0xFFFFu);
|
||||
}
|
||||
else
|
||||
{
|
||||
required.Add(0x0172u);
|
||||
if (root != (Landblock | 0x0172u)) required.Add(0x0173u);
|
||||
if (root == (Landblock | 0x0171u)) required.Add(0x0171u);
|
||||
required.Add(0x016Fu);
|
||||
}
|
||||
foreach (uint low in required)
|
||||
if (!frame.OrderedVisibleCells.Contains(Landblock | low))
|
||||
failures.Add($"step {i}: 0x{low:X4} missing from flood (root=0x{root & 0xFFFFu:X4}{(knifeEdge ? ", knife-edge" : "")})");
|
||||
if (!knifeEdge && frame.OutsideView.Polygons.Count == 0)
|
||||
failures.Add($"step {i}: outside view empty");
|
||||
|
||||
float area = 0f;
|
||||
if (frame.CellViews.TryGetValue(Landblock | 0x0172u, out var view))
|
||||
foreach (var p in view.Polygons)
|
||||
area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY);
|
||||
if (!knifeEdge && area < 0.5f)
|
||||
failures.Add(System.FormattableString.Invariant(
|
||||
$"step {i}: 0172 region collapsed (area={area:F3})"));
|
||||
// Monotone shrink as the eye recedes — allow float-noise upticks only.
|
||||
// Reset at root flips + knife-edge frames (legitimate discontinuities).
|
||||
if (root == prevRoot && !knifeEdge && prevArea != float.MaxValue && area > prevArea + 0.01f)
|
||||
failures.Add(System.FormattableString.Invariant(
|
||||
$"step {i}: 0172 region grew {prevArea:F3}->{area:F3} (oscillation)"));
|
||||
prevArea = knifeEdge ? float.MaxValue : area;
|
||||
prevRoot = root;
|
||||
}
|
||||
|
||||
Assert.True(failures.Count == 0,
|
||||
"Flood instability under a monotonic eye sweep (the §4 strobe):\n "
|
||||
+ string.Join("\n ", failures));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #120 repro (T5 gate, 2026-06-11): the T5 launch log fired
|
||||
/// `[pv-ERROR] in-place propagation tripwire at depth 128` on cottage
|
||||
/// interior cells 0xA9B40175/0174 (+0162, a different building) while
|
||||
/// the user walked the doorways — i.e. the eye-on-portal-plane regime.
|
||||
/// Sweep the eye ACROSS every portal plane of every cell in this
|
||||
/// building (±6 cm in 5 mm steps, looking through the opening), seeding
|
||||
/// <see cref="PortalVisibilityBuilder.Build"/> from BOTH sides' cells.
|
||||
/// The in-place growth's fixpoint invariant must hold at every step —
|
||||
/// the tripwire count stays 0.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void PortalPlaneCrossings_InPlacePropagationConverges()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var firings = new List<string>();
|
||||
|
||||
foreach (var cell in cells.Values)
|
||||
{
|
||||
for (int i = 0; i < cell.Portals.Count && i < cell.PortalPolygons.Count; i++)
|
||||
{
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly == null || poly.Length < 3) continue;
|
||||
if (i >= cell.ClipPlanes.Count) continue;
|
||||
var plane = cell.ClipPlanes[i];
|
||||
if (plane.Normal.LengthSquared() < 1e-6f) continue;
|
||||
|
||||
// Portal centroid + plane normal in WORLD space.
|
||||
var centroidLocal = Vector3.Zero;
|
||||
foreach (var v in poly) centroidLocal += v;
|
||||
centroidLocal /= poly.Length;
|
||||
var centroidWorld = Vector3.Transform(centroidLocal, cell.WorldTransform);
|
||||
var normalWorld = Vector3.Normalize(
|
||||
Vector3.TransformNormal(plane.Normal, cell.WorldTransform));
|
||||
|
||||
uint neighbourId = cell.Portals[i].OtherCellId == 0xFFFF
|
||||
? 0u
|
||||
: (Landblock | cell.Portals[i].OtherCellId);
|
||||
var roots = new List<LoadedCell> { cell };
|
||||
if (neighbourId != 0u && cells.TryGetValue(neighbourId, out var nb))
|
||||
roots.Add(nb);
|
||||
|
||||
for (int step = -12; step <= 12; step++)
|
||||
{
|
||||
var eye = centroidWorld + normalWorld * (step * 0.005f);
|
||||
// Look through the opening (along -normal), slightly down
|
||||
// — the portal fills the view, maximizing flood activity.
|
||||
var lookAt = centroidWorld - normalWorld * 2f + new Vector3(0f, 0f, -0.2f);
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, lookAt));
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
if (after != before)
|
||||
firings.Add(System.FormattableString.Invariant(
|
||||
$"cell=0x{cell.CellId:X8} portal#{i}->0x{cell.Portals[i].OtherCellId:X4} step={step} root=0x{root.CellId:X8} eye=({eye.X:F4},{eye.Y:F4},{eye.Z:F4})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(firings.Count == 0,
|
||||
"#120: in-place propagation convergence tripwire fired during the " +
|
||||
"portal-plane sweep:\n " + string.Join("\n ", firings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #120 repro attempt 2: eyes INSIDE each cell's volume (3×3 XY grid ×
|
||||
/// 2 Z levels) with full yaw (8) × pitch (3) direction sweeps — the
|
||||
/// walking-and-turning regime of the T5 session, including the steep
|
||||
/// pitches of the cellar stairs. Same invariant: the tripwire count
|
||||
/// stays 0 across every Build.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public void InCellDirectionSweep_InPlacePropagationConverges()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var firings = new List<string>();
|
||||
int builds = 0;
|
||||
|
||||
foreach (var cell in cells.Values)
|
||||
{
|
||||
var lo = cell.LocalBoundsMin;
|
||||
var hi = cell.LocalBoundsMax;
|
||||
if (hi.X - lo.X < 0.05f || hi.Y - lo.Y < 0.05f) continue;
|
||||
|
||||
for (int gx = 0; gx < 3; gx++)
|
||||
for (int gy = 0; gy < 3; gy++)
|
||||
for (int gz = 0; gz < 2; gz++)
|
||||
{
|
||||
var local = new Vector3(
|
||||
lo.X + (hi.X - lo.X) * (0.2f + 0.3f * gx),
|
||||
lo.Y + (hi.Y - lo.Y) * (0.2f + 0.3f * gy),
|
||||
lo.Z + (hi.Z - lo.Z) * (gz == 0 ? 0.3f : 0.8f));
|
||||
var eye = Vector3.Transform(local, cell.WorldTransform);
|
||||
|
||||
for (int yaw = 0; yaw < 8; yaw++)
|
||||
for (int pitch = -1; pitch <= 1; pitch++)
|
||||
{
|
||||
float a = yaw * MathF.PI / 4f;
|
||||
var dir = new Vector3(
|
||||
MathF.Cos(a), MathF.Sin(a), pitch * 0.9f);
|
||||
var lookAt = eye + Vector3.Normalize(dir) * 3f;
|
||||
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
PortalVisibilityBuilder.Build(cell, eye, lookup, ViewProjFor(eye, lookAt));
|
||||
builds++;
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
if (after != before)
|
||||
firings.Add(System.FormattableString.Invariant(
|
||||
$"cell=0x{cell.CellId:X8} eye=({eye.X:F3},{eye.Y:F3},{eye.Z:F3}) yaw={yaw} pitch={pitch}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"builds={builds} firings={firings.Count}");
|
||||
Assert.True(firings.Count == 0,
|
||||
"#120: convergence tripwire fired during the in-cell direction sweep:\n "
|
||||
+ string.Join("\n ", firings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic: microscope on the failing hop. Replays the two portal hops
|
||||
/// (0171→0173, then 0173→0172) through the PUBLIC PortalProjection APIs for the
|
||||
/// good step (47) and the glitch step (48), printing the homogeneous subject
|
||||
/// vertices and full-precision outputs at every stage — the concrete numbers for
|
||||
/// the collapsing intersection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_Hop2Microscope()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
var c171 = cells[Landblock | 0x0171u];
|
||||
var c173 = cells[Landblock | 0x0173u];
|
||||
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
// 0171's portal to 0173 is index 1; 0173's portal to 0172 is index 1 (room map).
|
||||
var poly171to173 = c171.PortalPolygons[1];
|
||||
var poly173to172 = c173.PortalPolygons[1];
|
||||
|
||||
foreach (int i in new[] { 47, 48 })
|
||||
{
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
var vp = ViewProjFor(eye, pivot);
|
||||
|
||||
_out.WriteLine($"########## step {i} (eyeX={ex:F2}) ##########");
|
||||
|
||||
// Hop 1: 0171 -> 0173 against the full screen.
|
||||
var subj1 = PortalProjection.ProjectToClip(poly171to173, c171.WorldTransform, vp);
|
||||
DumpClip("hop1 subject (0171->0173 portal, clip space)", subj1);
|
||||
var region173 = PortalProjection.ClipToRegion(subj1, new[]
|
||||
{
|
||||
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
|
||||
});
|
||||
DumpNdc("hop1 out = 0173 region", region173);
|
||||
if (region173.Length < 3) continue;
|
||||
|
||||
// Hop 2: 0173 -> 0172 against the 0173 region.
|
||||
var subj2 = PortalProjection.ProjectToClip(poly173to172, c173.WorldTransform, vp);
|
||||
DumpClip("hop2 subject (0173->0172 portal, clip space)", subj2);
|
||||
var region172 = PortalProjection.ClipToRegion(subj2, region173);
|
||||
DumpNdc("hop2 out = 0172 region", region172);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scratch: the homogeneous reciprocal primitive in isolation, on the
|
||||
/// synthetic fixture geometry that Build_AppliesReciprocalOtherPortalClip uses.</summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Scratch_ReciprocalPrimitive_SyntheticPair()
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||||
var vp = view * proj;
|
||||
|
||||
Vector3[] Quad(float cx, float cy, float hw, float hh, float z) => new[]
|
||||
{
|
||||
new Vector3(cx - hw, cy - hh, z), new Vector3(cx + hw, cy - hh, z),
|
||||
new Vector3(cx + hw, cy + hh, z), new Vector3(cx - hw, cy + hh, z),
|
||||
};
|
||||
|
||||
var wide = Quad(0f, 0f, 0.9f, 0.9f, -3f);
|
||||
var narrow = Quad(0f, 0f, 0.3f, 0.9f, -3f);
|
||||
|
||||
// Near-side region: wide portal clipped against the full screen.
|
||||
var wideClip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp);
|
||||
DumpClip("wide subject", wideClip);
|
||||
var wideRegion = PortalProjection.ClipToRegion(wideClip, new[]
|
||||
{
|
||||
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
|
||||
});
|
||||
DumpNdc("wide region (near-side)", wideRegion);
|
||||
|
||||
// Reciprocal: narrow portal, homogeneous, clipped against the wide region.
|
||||
var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp);
|
||||
DumpClip("narrow subject (reciprocal)", narrowClip);
|
||||
var tightened = PortalProjection.ClipToRegion(narrowClip, wideRegion);
|
||||
DumpNdc("tightened = narrow ∩ wide", tightened);
|
||||
|
||||
// Now the FULL Build on the same synthetic pair (mirrors
|
||||
// Build_AppliesReciprocalOtherPortalClip) with output dumps.
|
||||
var a = new LoadedCell
|
||||
{
|
||||
CellId = 0x0001, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo> { new CellPortalInfo(0x0002, 0, 0, 0) },
|
||||
};
|
||||
a.PortalPolygons.Add(wide);
|
||||
var b = new LoadedCell
|
||||
{
|
||||
CellId = 0x0002, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo> { new CellPortalInfo(0x0001, 0, 0, 0) },
|
||||
};
|
||||
b.PortalPolygons.Add(narrow);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001u] = a, [0x0002u] = b };
|
||||
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
|
||||
foreach (var kv in f.CellViews)
|
||||
foreach (var p in kv.Value.Polygons)
|
||||
{
|
||||
string s = "";
|
||||
foreach (var v in p.Vertices)
|
||||
s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})");
|
||||
_out.WriteLine($" Build view 0x{kv.Key:X4} [{p.Vertices.Length}]:{s}");
|
||||
}
|
||||
}
|
||||
|
||||
private void DumpClip(string label, Vector4[] verts)
|
||||
{
|
||||
string s = "";
|
||||
foreach (var v in verts)
|
||||
s += System.FormattableString.Invariant($" ({v.X:F4},{v.Y:F4},{v.Z:F4},w={v.W:F4})");
|
||||
_out.WriteLine($" {label} [{verts.Length}]:{s}");
|
||||
}
|
||||
|
||||
private void DumpNdc(string label, Vector2[] verts)
|
||||
{
|
||||
string s = "";
|
||||
foreach (var v in verts)
|
||||
s += System.FormattableString.Invariant($" ({v.X:F7},{v.Y:F7})");
|
||||
_out.WriteLine($" {label} [{verts.Length}]:{s}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic (no assertions yet): sweep the eye along the corner-press path —
|
||||
/// from inside the player's room (0172), across the doorway threshold (0173),
|
||||
/// into the neighbour room (0171) — looking back at the player throughout, and
|
||||
/// print the flood result per step. The defect signature: the PLAYER's room
|
||||
/// (0172) absent from the flood (or its view region empty) while the root is
|
||||
/// 0171/0173 — the screen area where the player stands then shows background.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_CornerPress_FloodAcrossDoorwayPlane()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadBuilding(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// The captured corner scenario: player parked in 0172, eye orbiting near the
|
||||
// doorway plane at head height (z=96.25), looking at the pivot.
|
||||
var player = new Vector3(159.936676f, 7.701012f, 94.000000f);
|
||||
var pivot = player + new Vector3(0f, 0f, 1.5f);
|
||||
|
||||
_out.WriteLine("step | eyeX (worldX | dPlane0171) | root | flood | 0171/0173/0172 polys | outside");
|
||||
for (int i = 0; i <= 60; i++)
|
||||
{
|
||||
// Sweep world-x 158.43 (inside 0172) -> 157.23 (inside 0171), 2 cm steps,
|
||||
// crossing the 0172 plane (~158.03) and the 0171 plane (~157.83).
|
||||
float ex = 158.43f - i * 0.02f;
|
||||
var eye = new Vector3(ex, 7.912722f, 96.248833f);
|
||||
uint root = RootCellFor(ex);
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[root], eye, lookup, ViewProjFor(eye, pivot));
|
||||
|
||||
string flood = "";
|
||||
foreach (uint id in frame.OrderedVisibleCells)
|
||||
flood += $" {id & 0xFFFFu:X4}";
|
||||
|
||||
string PolyInfo(uint low)
|
||||
{
|
||||
uint id = Landblock | low;
|
||||
if (!frame.CellViews.TryGetValue(id, out var view)) return "-";
|
||||
int n = view.Polygons.Count;
|
||||
float area = 0f;
|
||||
foreach (var p in view.Polygons)
|
||||
area += MathF.Max(0f, p.MaxX - p.MinX) * MathF.Max(0f, p.MaxY - p.MinY);
|
||||
return System.FormattableString.Invariant($"{n}:{area:F3}");
|
||||
}
|
||||
|
||||
string p71 = PolyInfo(0x0171u);
|
||||
string p73 = PolyInfo(0x0173u);
|
||||
string p72 = PolyInfo(0x0172u);
|
||||
_out.WriteLine(System.FormattableString.Invariant(
|
||||
$"{i,3} | {ex:F2} ({ex - Plane0171X,6:F3}) | {root & 0xFFFFu:X4} |{flood} | {p71} / {p73} / {p72} | out={frame.OutsideView.Polygons.Count}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -738,7 +738,6 @@ public sealed class EnvCellAlphaDrawSourceTests
|
|||
particleRenderer: null,
|
||||
portalDepthMask: null,
|
||||
Queue,
|
||||
(WorldRenderDiagnostics)RuntimeHelpers.GetUninitializedObject(typeof(WorldRenderDiagnostics)),
|
||||
(TerrainDrawDiagnosticsController)RuntimeHelpers.GetUninitializedObject(
|
||||
typeof(TerrainDrawDiagnosticsController)));
|
||||
Leaf = new WalkProductionLeafRenderer(
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class IndoorDrawPlanTests
|
||||
{
|
||||
private static ViewPolygon Quad() => new(new[]
|
||||
{ new Vector2(-1, -1), new Vector2(1, -1), new Vector2(1, 1), new Vector2(-1, 1) });
|
||||
|
||||
private static PortalVisibilityFrame FrameWith(params uint[] orderedCells)
|
||||
{
|
||||
var f = new PortalVisibilityFrame();
|
||||
foreach (var id in orderedCells)
|
||||
{
|
||||
f.OrderedVisibleCells.Add(id);
|
||||
var v = new CellView(); v.Add(Quad());
|
||||
f.CellViews[id] = v;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShellPass_IncludesEveryVisibleCell_NoFilter()
|
||||
{
|
||||
// The grey bug: a cell in OrderedVisibleCells must NEVER be dropped from the
|
||||
// shell pass. (Old code dropped cells lacking a ClipFrameAssembler slot.)
|
||||
var f = FrameWith(0x01, 0x02, 0x03);
|
||||
var plan = IndoorDrawPlan.ShellPass(f);
|
||||
Assert.Equal(new uint[] { 0x03, 0x02, 0x01 }, plan.Select(e => e.CellId).ToArray()); // reverse = far→near
|
||||
Assert.All(plan, e => Assert.NotEmpty(e.Slices));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShellPass_ExcludesEmptyViewCells()
|
||||
{
|
||||
var f = FrameWith(0x01);
|
||||
f.OrderedVisibleCells.Add(0x02); // present in the list…
|
||||
f.CellViews[0x02] = new CellView(); // …but empty view → not drawable
|
||||
var plan = IndoorDrawPlan.ShellPass(f);
|
||||
Assert.Equal(new uint[] { 0x01 }, plan.Select(e => e.CellId).ToArray());
|
||||
}
|
||||
}
|
||||
17
tests/AcDream.App.Tests/Rendering/InstalledDatTestPath.cs
Normal file
17
tests/AcDream.App.Tests/Rendering/InstalledDatTestPath.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
internal static class InstalledDatTestPath
|
||||
{
|
||||
internal static string? Resolve()
|
||||
{
|
||||
string? configured = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
|
||||
return configured;
|
||||
|
||||
string defaultPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
return Directory.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #113 phantom exterior staircase (2026-06-10): the Holtburg meeting hall
|
||||
/// (AAB3 building[0], model 0x010014C3 at AAB3-local (36,84,116)) shows its
|
||||
/// INTERIOR stair cells painted across the west exterior wall when viewed from
|
||||
/// outside. Attribution: retail clips drawn cell geometry to the accumulated
|
||||
/// portal aperture (Render::set_view portal scissor + polyClipFinish with
|
||||
/// planeMask=0xffffffff, decomp :343750/:427922); our DrawEnvCellShells routes
|
||||
/// per-cell clip planes via ClipFrameAssembler — but a slice whose aperture
|
||||
/// polygon exceeds the 8-plane budget falls back to slot 0 ("the renderer uses
|
||||
/// scissor for passes that need that fallback", ClipFrameAssembler.cs:13-15)
|
||||
/// and the shell pass never implemented that scissor → the cell draws fully
|
||||
/// unclipped. This harness replays the production outdoor per-building flood
|
||||
/// (PortalVisibilityBuilder.ConstructViewBuilding, the R-A2 path) + the
|
||||
/// production assembler from the user's click-time viewpoint and pins which
|
||||
/// hall cells draw and with what clip.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue113MeetingHallFloodTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue113MeetingHallFloodTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint Landblock = 0xAAB30000u;
|
||||
private const uint EnvironmentFilePrefix = 0x0D000000u;
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
return fromEnv;
|
||||
var def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
|
||||
// Mirrors CornerFloodReplayTests.LoadCell (EnvCellLandblockBuildBuilder shape).
|
||||
private static LoadedCell LoadCell(DatCollection dats, uint cellId)
|
||||
{
|
||||
var envCell = dats.Get<DatEnvCell>(cellId)
|
||||
?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found");
|
||||
var environment = dats.Get<DatEnvironment>(EnvironmentFilePrefix | envCell.EnvironmentId)
|
||||
?? throw new InvalidOperationException($"Environment 0x{envCell.EnvironmentId:X8} not found");
|
||||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct) || cellStruct is null)
|
||||
throw new InvalidOperationException($"CellStruct {envCell.CellStructure} missing");
|
||||
|
||||
var cellTransform =
|
||||
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
Matrix4x4.CreateTranslation(envCell.Position.Origin);
|
||||
Matrix4x4.Invert(cellTransform, out var inverse);
|
||||
|
||||
var boundsMin = new Vector3(float.MaxValue);
|
||||
var boundsMax = new Vector3(float.MinValue);
|
||||
foreach (var kvp in cellStruct.VertexArray.Vertices)
|
||||
{
|
||||
var v = kvp.Value;
|
||||
var pos = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
|
||||
boundsMin = Vector3.Min(boundsMin, pos);
|
||||
boundsMax = Vector3.Max(boundsMax, pos);
|
||||
}
|
||||
if (boundsMin.X == float.MaxValue) { boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; }
|
||||
|
||||
var portals = new List<CellPortalInfo>();
|
||||
var clipPlanes = new List<PortalClipPlane>();
|
||||
var portalPolygons = new List<Vector3[]>();
|
||||
|
||||
foreach (var portal in envCell.CellPortals)
|
||||
{
|
||||
portals.Add(new CellPortalInfo(
|
||||
portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags, portal.OtherPortalId));
|
||||
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||||
&& poly.VertexIds.Count >= 3
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)
|
||||
&& cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
|
||||
{
|
||||
var p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
|
||||
var p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
|
||||
var p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||||
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
||||
float d = -Vector3.Dot(normal, p0);
|
||||
// InsideSide from the dat PortalSide bit — mirrors the production fix
|
||||
// (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics
|
||||
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
|
||||
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
|
||||
clipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = normal, D = d, InsideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
clipPlanes.Add(default);
|
||||
}
|
||||
|
||||
Vector3[] polyVerts = Array.Empty<Vector3>();
|
||||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
|
||||
&& portalPoly.VertexIds.Count >= 3)
|
||||
{
|
||||
polyVerts = new Vector3[portalPoly.VertexIds.Count];
|
||||
bool allResolved = true;
|
||||
for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
|
||||
{
|
||||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||||
(ushort)portalPoly.VertexIds[vi], out var pv))
|
||||
polyVerts[vi] = new Vector3(pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
|
||||
else { allResolved = false; break; }
|
||||
}
|
||||
if (!allResolved) polyVerts = Array.Empty<Vector3>();
|
||||
}
|
||||
portalPolygons.Add(polyVerts);
|
||||
}
|
||||
|
||||
uint lbPrefix = cellId & 0xFFFF0000u;
|
||||
var visibleCells = new List<uint>();
|
||||
if (envCell.VisibleCells is not null)
|
||||
foreach (var lowId in envCell.VisibleCells)
|
||||
visibleCells.Add(lbPrefix | lowId);
|
||||
|
||||
return new LoadedCell
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldPosition = envCell.Position.Origin,
|
||||
WorldTransform = cellTransform,
|
||||
InverseWorldTransform = inverse,
|
||||
LocalBoundsMin = boundsMin,
|
||||
LocalBoundsMax = boundsMax,
|
||||
Portals = portals,
|
||||
ClipPlanes = clipPlanes,
|
||||
PortalPolygons = portalPolygons,
|
||||
VisibleCells = visibleCells,
|
||||
SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<uint, LoadedCell> LoadHall(DatCollection dats)
|
||||
{
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
for (uint low = 0x0100u; low <= 0x0106u; low++)
|
||||
{
|
||||
uint id = Landblock | low;
|
||||
cells[id] = LoadCell(dats, id);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replay the production outdoor flood + assembler from the user's
|
||||
/// click-time viewpoint (player AAB3-local (24.4, 83.4, 116), chase eye
|
||||
/// behind at eye height, looking at the hall's west door). Prints every
|
||||
/// hall cell that enters the drawn set with its slice slot / plane count /
|
||||
/// NDC AABB. The #113 defect signature: a hall cell drawn with a slice
|
||||
/// that has Slot==0 AND zero planes (the assembler's scissor-fallback
|
||||
/// contract) — DrawEnvCellShells draws that slice fully unclipped, so the
|
||||
/// interior staircase paints across the exterior wall.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WestApproach_HallCellSlices_HaveUsableClip()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadHall(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// Chase-camera sweep along the west approach: player walked from the
|
||||
// cottage (west) toward the hall; eye behind the player at z≈117.6,
|
||||
// looking at the hall's west door (local (29.4, 84.0, 117.3)).
|
||||
var lookAt = new Vector3(29.4f, 84.0f, 117.3f);
|
||||
int unclippedDrawn = 0;
|
||||
var report = new List<string>();
|
||||
|
||||
for (int i = 0; i <= 20; i++)
|
||||
{
|
||||
float ex = 14f + i * 0.5f; // eye local x 14 → 24
|
||||
var eye = new Vector3(ex, 83.4f, 117.6f);
|
||||
var vp = ViewProjFor(eye, lookAt);
|
||||
|
||||
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells.Values.ToList(), eye, lookup, vp, maxSeedDistance: 48f);
|
||||
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
var assembly = ClipFrameAssembler.Assemble(clipFrame, frame);
|
||||
|
||||
string flood = string.Join(" ", frame.OrderedVisibleCells.Select(c => $"{c & 0xFFFFu:X3}"));
|
||||
var lineParts = new List<string>();
|
||||
foreach (uint cellId in frame.OrderedVisibleCells)
|
||||
{
|
||||
if (!assembly.CellIdToViewSlices.TryGetValue(cellId, out var slices))
|
||||
{
|
||||
// No slices at all → DrawEnvCellShells falls to NoClipSlice → UNCLIPPED.
|
||||
lineParts.Add($"0x{cellId & 0xFFFFu:X3}:NO-SLICES(unclipped)");
|
||||
unclippedDrawn++;
|
||||
continue;
|
||||
}
|
||||
foreach (var s in slices)
|
||||
{
|
||||
bool unclipped = s.Slot == 0 && s.Planes.Length == 0;
|
||||
if (unclipped) unclippedDrawn++;
|
||||
string tag = unclipped ? "(UNCLIPPED)" : "";
|
||||
lineParts.Add(FormattableString.Invariant(
|
||||
$"0x{cellId & 0xFFFFu:X3}:slot{s.Slot}/p{s.Planes.Length}[{s.NdcAabb.X:F2},{s.NdcAabb.Y:F2}..{s.NdcAabb.Z:F2},{s.NdcAabb.W:F2}]{tag}"));
|
||||
}
|
||||
}
|
||||
report.Add(FormattableString.Invariant(
|
||||
$"eyeX={ex,5:F1} flood=[{flood}] {string.Join(" ", lineParts)}"));
|
||||
}
|
||||
|
||||
foreach (var line in report) _out.WriteLine(line);
|
||||
_out.WriteLine($"unclipped-drawn slices across sweep: {unclippedDrawn}");
|
||||
|
||||
// Documents the defect when it fires; flips to 0 once the shell pass
|
||||
// implements the assembler's scissor-fallback contract (or exact clip).
|
||||
Assert.True(unclippedDrawn == 0,
|
||||
$"#113: {unclippedDrawn} hall-cell slices would draw fully unclipped " +
|
||||
"(slot 0, no planes, no scissor) — interior stair geometry paints " +
|
||||
"across the exterior wall (the phantom staircase).");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #120 reproduction (2026-06-11): the armed tripwire self-attributed on the
|
||||
/// re-gate launch (regate-118-119-launch.log) — a pure TWO-CELL reciprocal
|
||||
/// ping-pong, 64 laps each: `chain root=0xA9B4015C eye=(109.995,37.158,96.249)
|
||||
/// cells: 0xA9B40162x64 0xA9B4015Cx64` and `root=0xA9B3010F
|
||||
/// eye=(175.771,-107.310,118.814) cells: 0xA9B30103x64 0xA9B3010Fx64`.
|
||||
///
|
||||
/// Mechanism: with the eye within PortalSideEpsilon (±1 cm — the T2-refuted-
|
||||
/// to-tighten root-lag tolerance; retail's is 0.0002) of the portal plane,
|
||||
/// the in-plane case counts as interior for BOTH cells, so views flow
|
||||
/// A→B→A…; each lap re-clips through two near-edge-on apertures whose
|
||||
/// intersection numerics wobble by more than CellView's 1e-3 dedup grid →
|
||||
/// every lap keys as a NEW polygon → 128-deep in-place recursion. The prior
|
||||
/// convergence sweeps (CornerFloodReplayTests) could not reproduce because
|
||||
/// they only load the corner building 0x016F-0x0175 — both firing pairs are
|
||||
/// OUTSIDE that set.
|
||||
///
|
||||
/// The fix class is the handoff's own prediction ("dedup admitting
|
||||
/// near-duplicates per lap"): a round-trip re-emission is, in exact math, a
|
||||
/// SUBSET of the region that originated it — CellView.Add must reject
|
||||
/// contained polygons, which makes union growth strictly area-increasing and
|
||||
/// the flood convergent regardless of clip-numerics drift.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue120ReciprocalPingPongTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue120ReciprocalPingPongTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
internal static Dictionary<uint, LoadedCell> LoadAllInteriorCells(DatCollection dats, uint landblock)
|
||||
{
|
||||
var lbi = dats.Get<DatLandBlockInfo>(landblock | 0xFFFEu);
|
||||
Assert.NotNull(lbi);
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi!.NumCells; low++)
|
||||
{
|
||||
uint id = landblock | low;
|
||||
try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id); }
|
||||
catch (InvalidOperationException) { /* sparse cell ids — skip */ }
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
public static readonly TheoryData<uint, uint, uint, float, float, float> CapturedSites = new()
|
||||
{
|
||||
// landblock, cellA (root at firing), cellB, eye x, y, z — straight from the chain dumps
|
||||
{ 0xA9B40000u, 0x015Cu, 0x0162u, 109.995f, 37.158f, 96.249f },
|
||||
{ 0xA9B30000u, 0x010Fu, 0x0103u, 175.771f, -107.310f, 118.814f },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The captured firing sites, swept over orientations (the dump has no view
|
||||
/// matrix) and over both cells as root. Invariants: the tripwire stays 0
|
||||
/// AND no CellView accumulates a pathological polygon pile (the duplicate
|
||||
/// build-up is the defect even below the depth-128 failsafe).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(CapturedSites))]
|
||||
public void CapturedPingPongSites_Converge(uint landblock, uint lowA, uint lowB, float ex, float ey, float ez)
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadAllInteriorCells(dats, landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
Assert.True(cells.ContainsKey(landblock | lowA), $"cell 0x{landblock | lowA:X8} not loaded");
|
||||
Assert.True(cells.ContainsKey(landblock | lowB), $"cell 0x{landblock | lowB:X8} not loaded");
|
||||
|
||||
var eye = new Vector3(ex, ey, ez);
|
||||
var roots = new[] { cells[landblock | lowA], cells[landblock | lowB] };
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var failures = new List<string>();
|
||||
int builds = 0, maxPolys = 0;
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
for (int yaw = 0; yaw < 8; yaw++)
|
||||
{
|
||||
float a = yaw * MathF.PI / 4f;
|
||||
foreach (float pitch in new[] { -0.4f, 0f, 0.4f })
|
||||
{
|
||||
var dir = Vector3.Normalize(new Vector3(MathF.Cos(a), MathF.Sin(a), pitch));
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, eye + dir * 3f));
|
||||
builds++;
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
|
||||
int polys = frame.CellViews.Count == 0 ? 0 : frame.CellViews.Values.Max(v => v.Polygons.Count);
|
||||
if (polys > maxPolys) maxPolys = polys;
|
||||
|
||||
if (after != before || polys > 32)
|
||||
failures.Add(FormattableString.Invariant(
|
||||
$"root=0x{root.CellId:X8} yaw={yaw} pitch={pitch} tripwire={after - before} maxCellPolys={polys}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"builds={builds} maxCellPolys={maxPolys} failures={failures.Count}");
|
||||
foreach (var f in failures) _out.WriteLine($" {f}");
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{builds} builds broke convergence at the captured #120 site (see output)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The geometric trigger, driven directly: eye swept through the
|
||||
/// both-sides-pass window (±PortalSideEpsilon = ±1 cm) of the reciprocal
|
||||
/// portal plane between the two captured cells, looking across and along
|
||||
/// the aperture. Same convergence invariants.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(CapturedSites))]
|
||||
public void PortalPlaneWindow_BothSidesPass_Converges(uint landblock, uint lowA, uint lowB, float ex, float ey, float ez)
|
||||
{
|
||||
_ = ex; _ = ey; _ = ez; // geometric variant derives its own eyes
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = LoadAllInteriorCells(dats, landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var cellA = cells[landblock | lowA];
|
||||
uint cellBId = landblock | lowB;
|
||||
|
||||
// the portal from A to B (the ping-pong pair)
|
||||
int portalIdx = -1;
|
||||
for (int i = 0; i < cellA.Portals.Count; i++)
|
||||
if (cellA.Portals[i].OtherCellId == (ushort)lowB) { portalIdx = i; break; }
|
||||
Assert.True(portalIdx >= 0, $"no portal 0x{lowA:X4}->0x{lowB:X4}");
|
||||
var poly = cellA.PortalPolygons[portalIdx];
|
||||
Assert.True(poly is { Length: >= 3 }, "portal polygon degenerate");
|
||||
|
||||
var centroidLocal = Vector3.Zero;
|
||||
foreach (var v in poly!) centroidLocal += v;
|
||||
centroidLocal /= poly.Length;
|
||||
var centroidWorld = Vector3.Transform(centroidLocal, cellA.WorldTransform);
|
||||
var plane = cellA.ClipPlanes[portalIdx];
|
||||
var normalWorld = Vector3.Normalize(Vector3.TransformNormal(plane.Normal, cellA.WorldTransform));
|
||||
|
||||
PortalVisibilityBuilder.ConvergenceTripwireCount = 0;
|
||||
var failures = new List<string>();
|
||||
int builds = 0, maxPolys = 0;
|
||||
|
||||
foreach (float off in new[] { -0.009f, -0.004f, 0f, 0.004f, 0.009f })
|
||||
{
|
||||
var eye = centroidWorld + normalWorld * off;
|
||||
foreach (var root in new[] { cellA, cells[cellBId] })
|
||||
{
|
||||
for (int yaw = 0; yaw < 8; yaw++)
|
||||
{
|
||||
float a = yaw * MathF.PI / 4f;
|
||||
var dir = Vector3.Normalize(new Vector3(MathF.Cos(a), MathF.Sin(a), 0.1f));
|
||||
int before = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, eye + dir * 3f));
|
||||
builds++;
|
||||
int after = PortalVisibilityBuilder.ConvergenceTripwireCount;
|
||||
|
||||
int polys = frame.CellViews.Count == 0 ? 0 : frame.CellViews.Values.Max(v => v.Polygons.Count);
|
||||
if (polys > maxPolys) maxPolys = polys;
|
||||
|
||||
if (after != before || polys > 32)
|
||||
failures.Add(FormattableString.Invariant(
|
||||
$"off={off:F3} root=0x{root.CellId:X8} yaw={yaw} tripwire={after - before} maxCellPolys={polys}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"builds={builds} maxCellPolys={maxPolys} failures={failures.Count}");
|
||||
foreach (var f in failures) _out.WriteLine($" {f}");
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{builds} builds broke convergence in the ±ε portal-plane window (see output)");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #124 — far-building interiors under an INTERIOR root. Retail seeds the
|
||||
/// look-in flood by clipping a building's aperture against the CURRENTLY
|
||||
/// INSTALLED view (PView::GetClip 0x005a4320 inside ConstructView(CBldPortal)
|
||||
/// 0x005a59a0): full screen outdoors, the accumulated doorway (outside) view
|
||||
/// when looked into from inside. These tests pin BuildFromExterior's
|
||||
/// seedRegion parameter — the port of that installed-view clip — against the
|
||||
/// real Holtburg corner-building door.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue124LookInSeedRegionTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue124LookInSeedRegionTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint ExitCellId = CornerFloodReplayTests.Landblock | 0x0170u;
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static (Dictionary<uint, LoadedCell> cells, LoadedCell exitCell, int exitIdx,
|
||||
Vector3 centroid, Vector3 outward) LoadFixture(DatCollection dats)
|
||||
{
|
||||
var cells = CornerFloodReplayTests.LoadBuilding(dats);
|
||||
var exitCell = cells[ExitCellId];
|
||||
|
||||
int exitIdx = -1;
|
||||
for (int i = 0; i < exitCell.Portals.Count; i++)
|
||||
{
|
||||
if (exitCell.Portals[i].OtherCellId == 0xFFFF && i < exitCell.PortalPolygons.Count
|
||||
&& exitCell.PortalPolygons[i].Length >= 3)
|
||||
{ exitIdx = i; break; }
|
||||
}
|
||||
Assert.True(exitIdx >= 0);
|
||||
|
||||
var localPoly = exitCell.PortalPolygons[exitIdx];
|
||||
Vector3 centroid = Vector3.Zero;
|
||||
foreach (var lp in localPoly)
|
||||
centroid += Vector3.Transform(lp, exitCell.WorldTransform);
|
||||
centroid /= localPoly.Length;
|
||||
|
||||
var plane = exitCell.ClipPlanes[exitIdx];
|
||||
var normal = Vector3.TransformNormal(plane.Normal, exitCell.WorldTransform);
|
||||
var cellCenter = Vector3.Transform(
|
||||
(exitCell.LocalBoundsMin + exitCell.LocalBoundsMax) * 0.5f, exitCell.WorldTransform);
|
||||
// outward = away from the cell interior.
|
||||
if (Vector3.Dot(normal, cellCenter - centroid) > 0)
|
||||
normal = -normal;
|
||||
return (cells, exitCell, exitIdx, centroid, Vector3.Normalize(normal));
|
||||
}
|
||||
|
||||
private static Vector4 ApertureNdcAabb(LoadedCell cell, int idx, Matrix4x4 viewProj)
|
||||
{
|
||||
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
||||
foreach (var lp in cell.PortalPolygons[idx])
|
||||
{
|
||||
var w = Vector3.Transform(lp, cell.WorldTransform);
|
||||
var c = Vector4.Transform(new Vector4(w, 1f), viewProj);
|
||||
Assert.True(c.W > 0.05f, "fixture eye must keep the aperture fully in front");
|
||||
minX = MathF.Min(minX, c.X / c.W); maxX = MathF.Max(maxX, c.X / c.W);
|
||||
minY = MathF.Min(minY, c.Y / c.W); maxY = MathF.Max(maxY, c.Y / c.W);
|
||||
}
|
||||
return new Vector4(minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
private static ViewPolygon Quad(float minX, float minY, float maxX, float maxY) =>
|
||||
new(new[]
|
||||
{
|
||||
new Vector2(minX, minY), new Vector2(maxX, minY),
|
||||
new Vector2(maxX, maxY), new Vector2(minX, maxY),
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void SeedRegion_ContainingAperture_Floods_DisjointRegion_DoesNot()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var (cells, exitCell, exitIdx, centroid, outward) = LoadFixture(dats);
|
||||
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// Eye OUTSIDE the building, 3 m in front of the exit door, gaze at it
|
||||
// — the look-in geometry of a viewer peering at this building through
|
||||
// some other opening.
|
||||
var eye = centroid + outward * 3f;
|
||||
var viewProj = ViewProjFor(eye, centroid);
|
||||
var ap = ApertureNdcAabb(exitCell, exitIdx, viewProj);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"aperture ndc=({ap.X:F3},{ap.Y:F3},{ap.Z:F3},{ap.W:F3})"));
|
||||
|
||||
// Sanity: the full-screen (outdoor-root) seed floods.
|
||||
var full = PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj);
|
||||
Assert.True(full.OrderedVisibleCells.Count > 0, "full-screen seed must flood");
|
||||
|
||||
// A region containing the aperture floods — and never MORE than the
|
||||
// full-screen seed (region-restricting can only shrink the flood).
|
||||
var containing = new[] { Quad(ap.X - 0.05f, ap.Y - 0.05f, ap.Z + 0.05f, ap.W + 0.05f) };
|
||||
var seeded = PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, containing);
|
||||
Assert.True(seeded.OrderedVisibleCells.Count > 0, "containing region must flood");
|
||||
Assert.True(seeded.OrderedVisibleCells.Count <= full.OrderedVisibleCells.Count);
|
||||
|
||||
// A region strictly disjoint from the aperture must not flood — the
|
||||
// doorway doesn't show this building, so its interior never builds
|
||||
// (retail: GetClip vs the installed view returns empty → no look-in).
|
||||
Assert.True(ap.Z < 0.70f || ap.X > -0.70f, "fixture aperture unexpectedly fills the screen");
|
||||
var disjoint = ap.Z < 0.70f
|
||||
? new[] { Quad(0.75f, 0.75f, 0.99f, 0.99f) }
|
||||
: new[] { Quad(-0.99f, -0.99f, -0.75f, -0.75f) };
|
||||
var none = PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj, float.PositiveInfinity, disjoint);
|
||||
Assert.True(none.OrderedVisibleCells.Count == 0,
|
||||
FormattableString.Invariant($"disjoint region flooded {none.OrderedVisibleCells.Count} cells"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EyeOnInteriorSide_ExitDoorNeverSeeds()
|
||||
{
|
||||
// The root's own doorway must not look-in on itself: the seed eye-side
|
||||
// test (retail ConstructView's sidedness vs portal_side) excludes any
|
||||
// aperture the eye is on the interior side of — this is what lets the
|
||||
// interior-root gather pass ALL nearby buildings including the
|
||||
// viewer's own without special-casing.
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var (cells, exitCell, _, centroid, outward) = LoadFixture(dats);
|
||||
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var eye = centroid - outward * 2f; // 2 m INSIDE the doorway
|
||||
var viewProj = ViewProjFor(eye, centroid);
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { exitCell }, eye, Lookup, viewProj);
|
||||
Assert.True(frame.OrderedVisibleCells.Count == 0,
|
||||
"an interior-side eye must not seed its own cell's exit portal");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #127 — per-building flood admissions are BISTABLE per frame under the
|
||||
/// outdoor root (the building-flap mechanism: tower roof/edges flap, #123
|
||||
/// buildings vanishing when running past). Captured live with the [viewer]
|
||||
/// fwd= probe (flap-fwd-capture2.log): a ~4 cm eye wobble flips the merged
|
||||
/// flood 24↔25 with a byte-identical gaze:
|
||||
/// flood=25 eye=(129.051,-15.636,99.512) fwd=(-0.7565,0.5877,-0.2869)
|
||||
/// flood=24 eye=(129.009,-15.610,99.503) fwd=(same)
|
||||
/// flood=25 eye=(129.051,-15.642,99.514) fwd=(same)
|
||||
/// (player parked at pCell=0xA9B40019, Holtburg town, A9B4-anchored world
|
||||
/// frame — A9B4-local == world; A9B3 cells get a (0,-192,0) block offset.)
|
||||
///
|
||||
/// This harness replays the captured (eye, fwd) pairs through the
|
||||
/// production outdoor pipeline — Build(outdoorNode) + per-building
|
||||
/// ConstructViewBuilding merged (RetailPViewRenderer.MergeNearbyBuildingFloods
|
||||
/// shape) — and DIFFS the admitted cell sets: the flipping cell names the
|
||||
/// bistable admission, and the bisect along the eye segment names the knife
|
||||
/// edge.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue127FloodFlipReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue127FloodFlipReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
// The captured flip pair (A9B4-anchored world frame).
|
||||
private static readonly Vector3 EyeA = new(129.051f, -15.636f, 99.512f);
|
||||
private static readonly Vector3 EyeB = new(129.009f, -15.610f, 99.503f);
|
||||
private static readonly Vector3 Fwd = Vector3.Normalize(new Vector3(-0.7565f, 0.5877f, -0.2869f));
|
||||
|
||||
private sealed record World(
|
||||
Dictionary<uint, LoadedCell> Cells,
|
||||
List<List<LoadedCell>> BuildingGroups,
|
||||
Func<uint, LoadedCell?> Lookup);
|
||||
|
||||
private static void LoadLandblockBuildings(
|
||||
DatCollection dats, uint landblock, Vector3 offset,
|
||||
Dictionary<uint, LoadedCell> cells, List<List<LoadedCell>> groups)
|
||||
{
|
||||
var lbi = dats.Get<DatLandBlockInfo>(landblock | 0xFFFEu);
|
||||
if (lbi is null) return;
|
||||
|
||||
// all interior cells (the lookup needs every portal-reachable cell)
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
|
||||
{
|
||||
uint id = landblock | low;
|
||||
try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id, offset); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
// per-building groups from the bldPortal stab lists (the dat's own
|
||||
// building→cells mapping; mirrors BuildingLoader's seed+walk result)
|
||||
if (lbi.Buildings is null) return;
|
||||
foreach (var bld in lbi.Buildings)
|
||||
{
|
||||
var set = new HashSet<uint>();
|
||||
if (bld.Portals is not null)
|
||||
foreach (var bp in bld.Portals)
|
||||
if (bp.StabList is not null)
|
||||
foreach (ushort low in bp.StabList)
|
||||
set.Add(landblock | low);
|
||||
var group = new List<LoadedCell>();
|
||||
foreach (uint id in set)
|
||||
if (cells.TryGetValue(id, out var c))
|
||||
group.Add(c);
|
||||
if (group.Count > 0)
|
||||
groups.Add(group);
|
||||
}
|
||||
}
|
||||
|
||||
private World? LoadWorld()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; }
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = new Dictionary<uint, LoadedCell>();
|
||||
var groups = new List<List<LoadedCell>>();
|
||||
// A9B4 = the anchor (world == local); the live gather spans the
|
||||
// streaming near window, so load a 5x5 block neighbourhood with
|
||||
// block offsets relative to the anchor.
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
uint lbX = (uint)(0xA9 + dx);
|
||||
uint lbY = (uint)(0xB4 + dy);
|
||||
uint lb = (lbX << 24) | (lbY << 16);
|
||||
LoadLandblockBuildings(dats, lb, new Vector3(dx * 192f, dy * 192f, 0f), cells, groups);
|
||||
}
|
||||
_out.WriteLine($"loaded {cells.Count} cells in {groups.Count} building groups");
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
return new World(cells, groups, lookup);
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 fwd, float fovY)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
// PortalBounds per group: the union AABB of the group's exit-portal
|
||||
// polygons in world space — exactly BuildingLoader's construction
|
||||
// (BuildingLoader.cs:114-144).
|
||||
private static (bool Has, Vector3 Min, Vector3 Max) PortalBoundsFor(List<LoadedCell> group)
|
||||
{
|
||||
bool has = false;
|
||||
var min = new Vector3(float.MaxValue);
|
||||
var max = new Vector3(float.MinValue);
|
||||
foreach (var cell in group)
|
||||
{
|
||||
int n = Math.Min(cell.Portals.Count, cell.PortalPolygons.Count);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (cell.Portals[i].OtherCellId != 0xFFFF) continue;
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly is null) continue;
|
||||
foreach (var v in poly)
|
||||
{
|
||||
var world = Vector3.Transform(v, cell.WorldTransform);
|
||||
has = true;
|
||||
min = Vector3.Min(min, world);
|
||||
max = Vector3.Max(max, world);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (has, min, max);
|
||||
}
|
||||
|
||||
// The production outdoor pipeline: full-screen outdoor root + per-building
|
||||
// exterior floods merged as a union (RetailPViewRenderer.DrawInside +
|
||||
// MergeNearbyBuildingFloods + MergeBuildingFrame), INCLUDING the gather's
|
||||
// per-building frustum pre-gate on PortalBounds (GameWindow:7542-7545) —
|
||||
// controlled by <paramref name="withPreGate"/> so the gate itself can be
|
||||
// implicated or exonerated.
|
||||
private static HashSet<uint> AdmittedCells(World w, Vector3 eye, Vector3 fwd, float fovY, bool withPreGate)
|
||||
{
|
||||
var viewProj = ViewProjFor(eye, fwd, fovY);
|
||||
var frustum = FrustumPlanes.FromViewProjection(viewProj);
|
||||
var outdoor = OutdoorCellNode.Build(0xA9B40019u);
|
||||
var pv = PortalVisibilityBuilder.Build(outdoor, eye, w.Lookup, viewProj);
|
||||
foreach (var group in w.BuildingGroups)
|
||||
{
|
||||
if (withPreGate)
|
||||
{
|
||||
var (has, min, max) = PortalBoundsFor(group);
|
||||
if (has && !FrustumCuller.IsAabbVisible(frustum, min, max))
|
||||
continue;
|
||||
}
|
||||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj);
|
||||
foreach (uint cellId in bf.OrderedVisibleCells)
|
||||
{
|
||||
if (!bf.CellViews.TryGetValue(cellId, out var srcView)) continue;
|
||||
if (pv.CellViews.TryGetValue(cellId, out var existing))
|
||||
{
|
||||
foreach (var p in srcView.Polygons) existing.Add(p);
|
||||
continue;
|
||||
}
|
||||
pv.CellViews[cellId] = srcView;
|
||||
pv.OrderedVisibleCells.Add(cellId);
|
||||
}
|
||||
}
|
||||
return new HashSet<uint>(pv.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduce + name: the captured 4 cm pair must admit the SAME cell set
|
||||
/// (a stable world does not flap a building interior for a 4 cm eye
|
||||
/// wobble at identical gaze). The diff names the bistable cell(s); the
|
||||
/// bisect prints the knife-edge location and transition count.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CapturedFlipPair_AdmissionIsStable()
|
||||
{
|
||||
var w = LoadWorld();
|
||||
if (w is null) return;
|
||||
|
||||
foreach (bool preGate in new[] { false, true })
|
||||
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f, 0.9f })
|
||||
{
|
||||
var a = AdmittedCells(w, EyeA, Fwd, fov, preGate);
|
||||
var b = AdmittedCells(w, EyeB, Fwd, fov, preGate);
|
||||
var onlyA = a.Except(b).OrderBy(x => x).ToList();
|
||||
var onlyB = b.Except(a).OrderBy(x => x).ToList();
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"preGate={preGate} fov={fov:F2}: |A|={a.Count} |B|={b.Count} onlyA=[{string.Join(",", onlyA.Select(x => $"0x{x:X8}"))}] onlyB=[{string.Join(",", onlyB.Select(x => $"0x{x:X8}"))}]"));
|
||||
|
||||
if (onlyA.Count == 0 && onlyB.Count == 0) continue;
|
||||
|
||||
// bisect the segment: count admission transitions per flipping cell
|
||||
foreach (uint cell in onlyA.Concat(onlyB))
|
||||
{
|
||||
int transitions = 0;
|
||||
bool? prev = null;
|
||||
for (int i = 0; i <= 40; i++)
|
||||
{
|
||||
var eye = Vector3.Lerp(EyeA, EyeB, i / 40f);
|
||||
bool admitted = AdmittedCells(w, eye, Fwd, fov, preGate).Contains(cell);
|
||||
if (prev.HasValue && admitted != prev.Value) transitions++;
|
||||
prev = admitted;
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" cell 0x{cell:X8}: {transitions} admission transition(s) along the 4 cm segment"));
|
||||
}
|
||||
|
||||
Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells");
|
||||
}
|
||||
}
|
||||
|
||||
// Centre of a building group's exit-portal AABB (world space).
|
||||
private static (bool Has, Vector3 Center) PortalCenterFor(List<LoadedCell> group)
|
||||
{
|
||||
var (has, min, max) = PortalBoundsFor(group);
|
||||
return (has, (min + max) * 0.5f);
|
||||
}
|
||||
|
||||
// Per-building admitted cells (this group only) at one (eye, gaze) — the
|
||||
// production per-building flood + optional PortalBounds frustum pre-gate.
|
||||
private static HashSet<uint> BuildingAdmits(
|
||||
World w, List<LoadedCell> group, Vector3 eye, Matrix4x4 viewProj,
|
||||
FrustumPlanes frustum, bool withPreGate)
|
||||
{
|
||||
var result = new HashSet<uint>();
|
||||
if (withPreGate)
|
||||
{
|
||||
var (has, min, max) = PortalBoundsFor(group);
|
||||
if (has && !FrustumCuller.IsAabbVisible(frustum, min, max))
|
||||
return result;
|
||||
}
|
||||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj);
|
||||
foreach (uint id in bf.OrderedVisibleCells)
|
||||
result.Add(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #127 distant-building churn detector. The captured 4 cm pair is now
|
||||
/// stable (the near-eye W=0 clip port), but the user symptom is buildings
|
||||
/// flickering when RUNNING PAST at a distance. This strafes the eye past
|
||||
/// each loaded building at several distances in 1 mm steps with the gaze
|
||||
/// fixed forward (the run-past geometry) and counts, per building cell, how
|
||||
/// many times its admission toggles over the monotone strafe. A stable
|
||||
/// flood toggles a cell AT MOST ONCE along a monotone eye path (it enters
|
||||
/// or leaves the view a single time); >=2 toggles is churn — the building
|
||||
/// flickers. preGate off vs on separates flood-math churn from the
|
||||
/// PortalBounds frustum pre-gate.
|
||||
///
|
||||
/// RESULT (2026-06-12, HEAD post-W=0-clip-port + #120 containment): ZERO
|
||||
/// churning cases across all 21 building groups x {10,30,60,120,190} m x
|
||||
/// 100 mm-steps, both preGate states. The near-eye knife-edge class the
|
||||
/// W=0 polyClipFinish port (987313a) killed was the distant-building
|
||||
/// flicker too; the user re-gate ("Seems to have been fixed") agrees.
|
||||
/// Now the REGRESSION PIN — it asserts zero churn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DistantBuildingStrafe_NoAdmissionChurn()
|
||||
{
|
||||
var w = LoadWorld();
|
||||
if (w is null) return;
|
||||
|
||||
const float fovY = MathF.PI / 3f;
|
||||
const float eyeHeight = 1.8f;
|
||||
const float strafeSpanM = 0.10f; // 10 cm strafe
|
||||
const int strafeSteps = 100; // 1 mm/step
|
||||
var distances = new[] { 10f, 30f, 60f, 120f, 190f };
|
||||
|
||||
int totalChurn = 0;
|
||||
foreach (bool preGate in new[] { false, true })
|
||||
{
|
||||
int worstToggles = 0;
|
||||
string worstDesc = "(none)";
|
||||
int churningCases = 0;
|
||||
|
||||
for (int gi = 0; gi < w.BuildingGroups.Count; gi++)
|
||||
{
|
||||
var group = w.BuildingGroups[gi];
|
||||
var (has, center) = PortalCenterFor(group);
|
||||
if (!has) continue;
|
||||
|
||||
foreach (float dist in distances)
|
||||
{
|
||||
// Eye south of the building at eye height, gaze NORTH toward
|
||||
// the building centre; strafe along world +X (run-past).
|
||||
var gaze = Vector3.Normalize(new Vector3(0f, 1f, -0.05f));
|
||||
var strafeDir = Vector3.Normalize(Vector3.Cross(Vector3.UnitZ, gaze)); // ~world +X
|
||||
var eyeBase = new Vector3(center.X, center.Y - dist, center.Z + eyeHeight)
|
||||
- strafeDir * (strafeSpanM * 0.5f);
|
||||
|
||||
var toggleCount = new Dictionary<uint, int>();
|
||||
var prevIn = new Dictionary<uint, bool>();
|
||||
for (int s = 0; s <= strafeSteps; s++)
|
||||
{
|
||||
var eye = eyeBase + strafeDir * (strafeSpanM * s / strafeSteps);
|
||||
var vp = ViewProjFor(eye, gaze, fovY);
|
||||
var frustum = FrustumPlanes.FromViewProjection(vp);
|
||||
var admits = BuildingAdmits(w, group, eye, vp, frustum, preGate);
|
||||
|
||||
var seen = new HashSet<uint>(admits);
|
||||
foreach (uint id in seen)
|
||||
{
|
||||
bool wasIn = prevIn.TryGetValue(id, out var p) && p;
|
||||
if (!wasIn && prevIn.ContainsKey(id))
|
||||
toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1;
|
||||
prevIn[id] = true;
|
||||
}
|
||||
foreach (var id in new List<uint>(prevIn.Keys))
|
||||
if (!seen.Contains(id))
|
||||
{
|
||||
if (prevIn[id])
|
||||
toggleCount[id] = toggleCount.GetValueOrDefault(id) + 1;
|
||||
prevIn[id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, toggles) in toggleCount)
|
||||
{
|
||||
if (toggles < 2) continue; // <=1 = clean enter/leave
|
||||
churningCases++;
|
||||
if (toggles > worstToggles)
|
||||
{
|
||||
worstToggles = toggles;
|
||||
worstDesc = FormattableString.Invariant(
|
||||
$"group#{gi} dist={dist:F0}m cell=0x{id:X8} toggles={toggles}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"preGate={preGate}: churningCases={churningCases} worst={worstDesc} (worstToggles={worstToggles})"));
|
||||
totalChurn += churningCases;
|
||||
}
|
||||
|
||||
Assert.True(totalChurn == 0,
|
||||
$"{totalChurn} distant-building admission churn case(s) — a building's cells toggle >=2x " +
|
||||
"over a monotone run-past strafe (the #127 flicker); see output for the worst building/distance/cell");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
using System;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatSetup = DatReaderWriter.DBObjs.Setup;
|
||||
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #131 diagnostic (throwaway): identify the Holtburg portal among the
|
||||
/// outside-stage setup ids captured by the [outstage] probe, by dumping each
|
||||
/// candidate setup's parts + bounds from the dat. The portal's setup is the
|
||||
/// translucent swirl; lamp posts / creatures / signs identify by part shape.
|
||||
/// </summary>
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue131SetupProbeTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue131SetupProbeTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
/// <summary>#131: from the captured cottage-interior frame (the user's
|
||||
/// portal-missing viewpoint), does the look-in flood admit the hall's
|
||||
/// PORCH cell 0xA9B4017A (the portal's owner cell, pinned by the teleport
|
||||
/// pCell flip)? If not admitted, no pass can draw the swirl regardless of
|
||||
/// the emitter plumbing.</summary>
|
||||
[Fact]
|
||||
public void Diagnostic_LookInFlood_AdmitsHallPorchFromCottage()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, 0xA9B40000u);
|
||||
_out.WriteLine(FormattableString.Invariant($"loaded {cells.Count} A9B4 interior cells; hasPorch017A={cells.ContainsKey(0xA9B4017Au)}"));
|
||||
AcDream.App.Rendering.LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// The captured frame: [viewer] root=0xA9B40171 eye=(155.255,14.533,96.074)
|
||||
// fwd=(0.0702,0.9554,-0.2869) (portal-owner-verdicts.log:135118).
|
||||
var eye = new System.Numerics.Vector3(155.255f, 14.533f, 96.074f);
|
||||
var fwd = new System.Numerics.Vector3(0.0702f, 0.9554f, -0.2869f);
|
||||
var view = System.Numerics.Matrix4x4.CreateLookAt(eye, eye + fwd, System.Numerics.Vector3.UnitZ);
|
||||
var proj = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 1f, 5000f);
|
||||
var viewProj = view * proj;
|
||||
|
||||
var root = cells[0xA9B40171u];
|
||||
var pv = AcDream.App.Rendering.PortalVisibilityBuilder.Build(
|
||||
root, eye, Lookup, viewProj,
|
||||
buildingMembership: null);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"main flood={pv.OrderedVisibleCells.Count} outPolys={pv.OutsideView.Polygons.Count}"));
|
||||
|
||||
var lookIn = AcDream.App.Rendering.PortalVisibilityBuilder.BuildFromExterior(
|
||||
cells.Values, eye, Lookup, viewProj,
|
||||
float.PositiveInfinity, pv.OutsideView.Polygons);
|
||||
var sb = new System.Text.StringBuilder("look-in admitted:");
|
||||
foreach (uint id in lookIn.OrderedVisibleCells)
|
||||
sb.Append(FormattableString.Invariant($" 0x{id & 0xFFFFu:X4}"));
|
||||
_out.WriteLine(sb.ToString());
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"porch 0x017A admitted: {lookIn.OrderedVisibleCells.Contains(0xA9B4017Au)}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostic_DumpOutstageCandidateSetups()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
uint[] candidates =
|
||||
{
|
||||
0x020010AC, // 0x7A9B4050 PASS r=11.9 — portal candidate A
|
||||
0x02000B8E, // 0x7A9B403B PASS r=11.6 — portal candidate B
|
||||
0x020019FF, // many instances (lamp posts?)
|
||||
0x02000290,
|
||||
0x02000001, // baseline (human?)
|
||||
0x02000E08,
|
||||
};
|
||||
|
||||
foreach (uint setupId in candidates)
|
||||
{
|
||||
var setup = dats.Get<DatSetup>(setupId);
|
||||
if (setup is null)
|
||||
{
|
||||
_out.WriteLine(FormattableString.Invariant($"setup 0x{setupId:X8}: NOT FOUND"));
|
||||
continue;
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"setup 0x{setupId:X8}: parts={setup.Parts.Count}"));
|
||||
int shown = 0;
|
||||
foreach (uint partId in setup.Parts)
|
||||
{
|
||||
if (shown++ >= 4) { _out.WriteLine(" ..."); break; }
|
||||
var gfx = dats.Get<DatGfxObj>(partId);
|
||||
if (gfx is null) { _out.WriteLine(FormattableString.Invariant($" part 0x{partId:X8}: not found")); continue; }
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append(FormattableString.Invariant(
|
||||
$" part 0x{partId:X8}: polys={gfx.Polygons.Count} verts={gfx.VertexArray.Vertices.Count} surfaces=["));
|
||||
int sShown = 0;
|
||||
foreach (uint surfId in gfx.Surfaces)
|
||||
{
|
||||
if (sShown++ >= 6) { sb.Append(" ..."); break; }
|
||||
sb.Append(FormattableString.Invariant($" 0x{surfId:X8}"));
|
||||
}
|
||||
sb.Append(" ]");
|
||||
_out.WriteLine(sb.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #176 (purple flashing at corridor seams, camera-angle dependent) + #177
|
||||
/// (stairs pop in/out) — headless portal-flood replay in the Facility Hub
|
||||
/// landblock 0x8A02. The unified hypothesis after the dat + draw-path reads:
|
||||
/// both artifacts are FLOOD ADMISSION instability (a cell dropping out of
|
||||
/// PortalVisibilityBuilder's admitted set paints the fog-purple clear color
|
||||
/// where its geometry was; stair cells failing admission = the pop).
|
||||
///
|
||||
/// Production-matched inputs: Build(root, eye, lookup, viewProj,
|
||||
/// buildingMembership: null) — the production call shape (FW3.3 retired drawLiftZ)
|
||||
/// mirrors RetailPViewRenderer.DrawInside.
|
||||
///
|
||||
/// Scenarios:
|
||||
/// A. #177 approach — stand in corridor 0x0178, look +X at the stair ramp
|
||||
/// (0x0182) and the lower cell (0x0183): are they admitted?
|
||||
/// B. #177 descent — eye path down the ramp crossing into 0x0183: does
|
||||
/// 0x0182 (the ramp geometry's owner) drop near the transit?
|
||||
/// C. #176 gaze sweep — eye parked in 0x016E near the 0x017A seam, yaw
|
||||
/// sweep at several pitches: any cell admitted at angle k, gone at k+1,
|
||||
/// back at k+2 (the bistability signature)?
|
||||
/// D. #176 walk — eye tracks down the corridor across two seams, gaze
|
||||
/// locked +X: per-step admitted-set diffs (drop-for-one-step churn).
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue176177FacilityHubFloodReplayTests
|
||||
{
|
||||
private const uint FacilityHub = 0x8A020000u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue176177FacilityHubFloodReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static List<uint> Flood(
|
||||
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
|
||||
buildingMembership: null);
|
||||
var result = new List<uint>(frame.OrderedVisibleCells);
|
||||
result.Sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string CellSetString(IEnumerable<uint> ids)
|
||||
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
|
||||
|
||||
[Fact]
|
||||
public void ScenarioA_StairApproach_AdmissionsFromCorridor()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
Assert.True(cells.ContainsKey(FacilityHub | 0x0178u), "0x0178 not loaded");
|
||||
|
||||
// 0x0178 spans x<=95 at z −6..−3 (floor −6); the ramp cell 0x0182 runs
|
||||
// x 95→98.33 descending; 0x0183 continues at z −9 beyond x=98.33.
|
||||
// Eye at standing height (~1.7 m above the −6 floor), approaching the
|
||||
// stair portal at x=95, gazing +X with a slight downward pitch (the
|
||||
// natural look at a descending stair).
|
||||
foreach (float eyeX in new[] { 88f, 90f, 92f, 94f, 94.9f })
|
||||
{
|
||||
var eye = new Vector3(eyeX, -40f, -4.3f);
|
||||
foreach (var (gaze, label) in new (Vector3, string)[]
|
||||
{
|
||||
(new Vector3(1f, 0f, 0f), "level"),
|
||||
(Vector3.Normalize(new Vector3(1f, 0f, -0.35f)), "pitch-19"),
|
||||
(Vector3.Normalize(new Vector3(1f, 0f, -0.7f)), "pitch-35"),
|
||||
})
|
||||
{
|
||||
var visible = Flood(cells, FacilityHub | 0x0178u, eye, gaze);
|
||||
bool ramp = visible.Contains(FacilityHub | 0x0182u);
|
||||
bool lower = visible.Contains(FacilityHub | 0x0183u);
|
||||
_out.WriteLine($"eyeX={eyeX,5:F1} gaze={label,-8} flood={visible.Count,2} " +
|
||||
$"ramp0182={(ramp ? "Y" : "MISSING")} lower0183={(lower ? "Y" : "MISSING")} " +
|
||||
$"[{CellSetString(visible)}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_ScenarioB_StairDescent_RampCellRetention()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
// Descend the ramp: floor z goes −6 (x=95) → −9 (x=98.33), then flat.
|
||||
// Eye rides ~1.7 m above the local floor. Root = the cell containing
|
||||
// the eye by x-range (0x0178 x<95, ramp 0x0182 95..98.33, 0x0183 after).
|
||||
// Gaze: forward and slightly down (running down stairs).
|
||||
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.4f));
|
||||
List<uint>? prev = null;
|
||||
for (float x = 94.0f; x <= 100.5f; x += 0.1f)
|
||||
{
|
||||
float floorZ = x < 95f ? -6f
|
||||
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
|
||||
: -9f;
|
||||
var eye = new Vector3(x, -40f, floorZ + 1.7f);
|
||||
uint rootId = x < 95f ? FacilityHub | 0x0178u
|
||||
: x < 98.333f ? FacilityHub | 0x0182u
|
||||
: FacilityHub | 0x0183u;
|
||||
|
||||
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||
bool ramp = visible.Contains(FacilityHub | 0x0182u);
|
||||
bool upper = visible.Contains(FacilityHub | 0x0178u);
|
||||
|
||||
string diff = "";
|
||||
if (prev is not null)
|
||||
{
|
||||
var removed = prev.Except(visible).ToList();
|
||||
var added = visible.Except(prev).ToList();
|
||||
if (removed.Count > 0) diff += $" REMOVED=[{CellSetString(removed)}]";
|
||||
if (added.Count > 0) diff += $" added=[{CellSetString(added)}]";
|
||||
}
|
||||
_out.WriteLine($"x={x,6:F1} root={rootId & 0xFFFFu:X4} eyeZ={eye.Z,6:F2} flood={visible.Count,2} " +
|
||||
$"ramp0182={(ramp ? "Y" : "MISSING")} up0178={(upper ? "Y" : "-")}{diff}");
|
||||
prev = visible;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_ScenarioC_CorridorSeamGazeSweep_Bistability()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
// Parked eye in 0x016E near the 0x017A seam (x=85), standing height.
|
||||
var eye = new Vector3(83.5f, -40f, -4.3f);
|
||||
uint rootId = FacilityHub | 0x016Eu;
|
||||
|
||||
int churnEvents = 0;
|
||||
foreach (float pitchZ in new[] { 0f, -0.35f, -0.75f, -1.2f })
|
||||
{
|
||||
List<uint>? prevSet = null;
|
||||
float prevYaw = 0f;
|
||||
var perYawSets = new List<(float yaw, List<uint> set)>();
|
||||
for (float yawDeg = -180f; yawDeg <= 180f; yawDeg += 2f)
|
||||
{
|
||||
float rad = yawDeg * MathF.PI / 180f;
|
||||
var gaze = Vector3.Normalize(new Vector3(MathF.Cos(rad), MathF.Sin(rad), pitchZ));
|
||||
var visible = Flood(cells, rootId, eye, gaze);
|
||||
perYawSets.Add((yawDeg, visible));
|
||||
|
||||
if (prevSet is not null)
|
||||
{
|
||||
var removed = prevSet.Except(visible).ToList();
|
||||
var added = visible.Except(prevSet).ToList();
|
||||
if (removed.Count > 0 || added.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"pitch={pitchZ,5:F2} yaw {prevYaw,6:F0}->{yawDeg,6:F0}: " +
|
||||
$"flood {prevSet.Count}->{visible.Count}" +
|
||||
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
|
||||
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
|
||||
}
|
||||
}
|
||||
prevSet = visible;
|
||||
prevYaw = yawDeg;
|
||||
}
|
||||
|
||||
// Bistability: a cell present at yaw k, absent at k+1, present at k+2.
|
||||
for (int i = 2; i < perYawSets.Count; i++)
|
||||
{
|
||||
var flicker = perYawSets[i - 2].set
|
||||
.Intersect(perYawSets[i].set)
|
||||
.Except(perYawSets[i - 1].set)
|
||||
.ToList();
|
||||
if (flicker.Count > 0)
|
||||
{
|
||||
churnEvents++;
|
||||
_out.WriteLine($">>> BISTABLE pitch={pitchZ:F2} yaw={perYawSets[i - 1].yaw:F0}: " +
|
||||
$"cells [{CellSetString(flicker)}] dropped for ONE 2-degree step");
|
||||
}
|
||||
}
|
||||
}
|
||||
_out.WriteLine($"bistable one-step drop events: {churnEvents}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE production lag-window scenario (from launch-137-gate2.log
|
||||
/// [cell-transit] lines): membership transits fire 0.1–0.6 m PAST the
|
||||
/// portal plane in the travel direction (016E→017A at x=85.33–85.47 vs
|
||||
/// the plane at x=85.00; 0182→0183 at 98.56–98.64 vs 98.33). The render
|
||||
/// root (viewer cell, same membership machinery) therefore holds the OLD
|
||||
/// cell while the camera eye is already beyond the boundary portal's
|
||||
/// plane. This scenario reproduces exactly that window: root=old cell,
|
||||
/// eye stepped across and past the plane, gaze forward. If the forward
|
||||
/// chain (the next corridor cells) drops inside the window, that is #176
|
||||
/// (purple = fog clear color where the forward cells' geometry was) and
|
||||
/// #177(a)/(c) at the stair transit.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
[InlineData(0x016Eu, 0x017Au, 85.00f, -4.3f)] // corridor seam, plane x=85
|
||||
[InlineData(0x0182u, 0x0183u, 98.333f, -7.3f)] // stair-bottom transit, plane x=98.33
|
||||
public void Diagnostic_ScenarioE_RootLagWindow_ForwardChainRetention(
|
||||
uint rootLow, uint forwardLow, float planeX, float eyeZ)
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
uint rootId = FacilityHub | rootLow;
|
||||
uint forwardId = FacilityHub | forwardLow;
|
||||
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
|
||||
|
||||
_out.WriteLine($"root=0x{rootLow:X4} forward=0x{forwardLow:X4} plane x={planeX:F2} " +
|
||||
"(eye sweeps across; root HELD at the old cell = the production lag window)");
|
||||
foreach (float dx in new[] { -0.30f, -0.10f, -0.02f, 0.00f, 0.02f, 0.05f, 0.10f, 0.20f, 0.30f, 0.45f, 0.60f })
|
||||
{
|
||||
var eye = new Vector3(planeX + dx, -40f, eyeZ);
|
||||
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||
bool fwd = visible.Contains(forwardId);
|
||||
_out.WriteLine($" eyeX=plane{(dx >= 0 ? "+" : "")}{dx:F2} flood={visible.Count,2} " +
|
||||
$"forward={(fwd ? "Y" : ">>> DROPPED <<<")} [{CellSetString(visible)}]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_ScenarioD_CorridorWalk_PerStepChurn()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
|
||||
// Walk 0x0165 → 0x016E → 0x017A along y=−40 (seams at x=75 and x=85),
|
||||
// gaze locked +X, slight downward pitch (the running view). Root flips
|
||||
// by x-range at the seams (the camera transits the same portals).
|
||||
var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f));
|
||||
List<uint>? prev = null;
|
||||
int churn = 0;
|
||||
for (float x = 71f; x <= 89f; x += 0.05f)
|
||||
{
|
||||
uint rootId = x < 75f ? FacilityHub | 0x0165u
|
||||
: x < 85f ? FacilityHub | 0x016Eu
|
||||
: FacilityHub | 0x017Au;
|
||||
if (!cells.ContainsKey(rootId)) continue;
|
||||
var eye = new Vector3(x, -40f, -4.3f);
|
||||
var visible = Flood(cells, rootId, eye, gazeDir);
|
||||
|
||||
if (prev is not null)
|
||||
{
|
||||
var removed = prev.Except(visible).ToList();
|
||||
var added = visible.Except(prev).ToList();
|
||||
if (removed.Count > 0 || added.Count > 0)
|
||||
{
|
||||
churn++;
|
||||
_out.WriteLine($"x={x,6:F2} root={rootId & 0xFFFFu:X4} flood={visible.Count,2}" +
|
||||
(removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") +
|
||||
(added.Count > 0 ? $" added=[{CellSetString(added)}]" : ""));
|
||||
}
|
||||
}
|
||||
prev = visible;
|
||||
}
|
||||
_out.WriteLine($"admitted-set change events over the walk: {churn}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,727 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
using AcDream.App.Rendering.Scene.Arch;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
|
||||
using DatSetup = DatReaderWriter.DBObjs.Setup;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #177 decisive full-chain replay: drive the REAL <see cref="RetailChaseCamera"/>
|
||||
/// + REAL <see cref="PhysicsCameraCollisionProbe"/> down the Facility Hub
|
||||
/// staircase against the REAL BSP, then feed the swept (ViewerCellId, eye,
|
||||
/// View*Projection) into <see cref="PortalVisibilityBuilder.Build"/> — exactly
|
||||
/// the production coupling (GameWindow ~9055-9065 + RetailPViewRenderer.DrawInside).
|
||||
///
|
||||
/// The earlier Issue176177 replays fed SYNTHETIC (root, eye) pairs: Scenario A
|
||||
/// admitted the stairs fine from a coherent corridor root, Scenario E collapsed
|
||||
/// the flood 12->1 but only because it HELD the root at the old cell while the
|
||||
/// eye crossed a portal plane. This test removes the assumption: the root IS the
|
||||
/// swept camera cell, the eye IS the swept camera eye, both from the same sweep.
|
||||
/// If the stairs still collapse, #177 is a production root/eye coupling defect
|
||||
/// and the printed per-portal side-test D names the failing gate. If they stay
|
||||
/// admitted, the flood is exonerated and the artifact lives downstream.
|
||||
/// </summary>
|
||||
[Collection(CameraDiagnosticsCollection.Name)]
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue177StairDescentCameraFloodTests
|
||||
{
|
||||
private const uint FacilityHub = 0x8A020000u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue177StairDescentCameraFloodTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static string? ResolveDatDir()
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv;
|
||||
var def = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents", "Asheron's Call");
|
||||
return Directory.Exists(def) ? def : null;
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildHubEngine(DatCollection dats)
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
for (uint low = 0x0100u; low <= 0x01FFu; low++)
|
||||
{
|
||||
uint id = FacilityHub | low;
|
||||
var datCell = dats.Get<DatEnvCell>(id);
|
||||
if (datCell is null) continue;
|
||||
var environment = dats.Get<DatEnvironment>(0x0D000000u | datCell.EnvironmentId);
|
||||
if (environment is null) continue;
|
||||
if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) || cellStruct is null)
|
||||
continue;
|
||||
var world = Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) *
|
||||
Matrix4x4.CreateTranslation(datCell.Position.Origin);
|
||||
cache.CacheCellStruct(id, datCell, cellStruct, world);
|
||||
}
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||||
engine.AddLandblock(FacilityHub, new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
// Analytic ramp floor from Scenario B: flat -6 (x<95), descends to -9 across
|
||||
// x 95..98.333, then flat -9.
|
||||
private static float FloorZ(float x)
|
||||
=> x < 95f ? -6f
|
||||
: x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f
|
||||
: -9f;
|
||||
|
||||
// Replicated side test (PortalVisibilityBuilder.CameraOnInteriorSide is private).
|
||||
private const float PortalSideEpsilon = 0.01f;
|
||||
private static bool EyeInteriorSide(LoadedCell cell, int i, Vector3 eye)
|
||||
{
|
||||
if (i >= cell.ClipPlanes.Count) return true;
|
||||
var pl = cell.ClipPlanes[i];
|
||||
if (pl.Normal.LengthSquared() < 1e-8f) return true;
|
||||
var local = Vector3.Transform(eye, cell.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(pl.Normal, local) + pl.D;
|
||||
return pl.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
|
||||
}
|
||||
private static float SideD(LoadedCell cell, int i, Vector3 eye)
|
||||
{
|
||||
if (i >= cell.ClipPlanes.Count) return float.NaN;
|
||||
var pl = cell.ClipPlanes[i];
|
||||
if (pl.Normal.LengthSquared() < 1e-8f) return float.NaN;
|
||||
var local = Vector3.Transform(eye, cell.InverseWorldTransform);
|
||||
return Vector3.Dot(pl.Normal, local) + pl.D;
|
||||
}
|
||||
|
||||
private static string CellSet(IEnumerable<uint> ids)
|
||||
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_StairCellComposition_ShellVsStatics()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
foreach (uint low in new uint[] { 0x015E, 0x015F, 0x01C8, 0x01C4, 0x01C9, 0x0210, 0x020E, 0x01C1, 0x01C0 })
|
||||
{
|
||||
uint id = FacilityHub | low;
|
||||
var envCell = dats.Get<DatEnvCell>(id);
|
||||
if (envCell is null) { _out.WriteLine($"0x{low:X4}: (no dat cell)"); continue; }
|
||||
var environment = dats.Get<DatEnvironment>(0x0D000000u | envCell.EnvironmentId);
|
||||
environment!.Cells.TryGetValue(envCell.CellStructure, out var cs);
|
||||
|
||||
int polys = cs?.Polygons.Count ?? -1;
|
||||
int verts = cs?.VertexArray.Vertices.Count ?? -1;
|
||||
var mn = new Vector3(float.MaxValue); var mx = new Vector3(float.MinValue);
|
||||
if (cs is not null)
|
||||
foreach (var kv in cs.VertexArray.Vertices)
|
||||
{ var o = kv.Value.Origin; var v = new Vector3(o.X, o.Y, o.Z); mn = Vector3.Min(mn, v); mx = Vector3.Max(mx, v); }
|
||||
|
||||
int statics = envCell.StaticObjects?.Count ?? 0;
|
||||
bool seenOut = (envCell.Flags & DatReaderWriter.Enums.EnvCellFlags.SeenOutside) != 0;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"0x{low:X4} env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} shellPolys={polys} shellVerts={verts} bounds=[{mn.X:F1},{mn.Y:F1},{mn.Z:F1}]..[{mx.X:F1},{mx.Y:F1},{mx.Z:F1}] portals={envCell.CellPortals.Count} statics={statics} seenOut={seenOut}"));
|
||||
if (envCell.StaticObjects is not null)
|
||||
foreach (var stab in envCell.StaticObjects)
|
||||
{
|
||||
var o = stab.Frame.Origin;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" static Id=0x{stab.Id:X8} kind={(stab.Id >> 24 == 0x02 ? "Setup" : stab.Id >> 24 == 0x01 ? "GfxObj" : "?")} pos=({o.X:F2},{o.Y:F2},{o.Z:F2})"));
|
||||
}
|
||||
|
||||
// Portal normals (local space) — horizontal (|Nz|~1) = FLOOR/CEILING portal
|
||||
// (the edge-on-clip suspect); vertical (|Nz|~0) = wall/doorway portal.
|
||||
var lc = renderLookup(id);
|
||||
if (lc is not null)
|
||||
for (int i = 0; i < lc.Portals.Count; i++)
|
||||
{
|
||||
var n = i < lc.ClipPlanes.Count ? lc.ClipPlanes[i].Normal : Vector3.Zero;
|
||||
string kind = MathF.Abs(n.Z) > 0.7f ? "FLOOR/CEIL" : MathF.Abs(n.Z) < 0.3f ? "wall" : "slope";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" portal[{i}]->0x{lc.Portals[i].OtherCellId:X4} N=({n.X:F2},{n.Y:F2},{n.Z:F2}) {kind} inside={lc.ClipPlanes[i].InsideSide}"));
|
||||
}
|
||||
}
|
||||
|
||||
LoadedCell? renderLookup(uint id)
|
||||
{
|
||||
try { return CornerFloodReplayTests.LoadCell(dats, id); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 2: retargeted from the deleted
|
||||
/// <c>ShadowObjectRegistry.ComputeStaticRenderCells</c> pure-function
|
||||
/// side channel to the production
|
||||
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> transaction
|
||||
/// (the SAME BSP-collision + <c>partArray:</c> recipe
|
||||
/// <c>LandblockPhysicsPublisher.PublishStaticEntity</c> uses, and the
|
||||
/// same one <c>RetailCellArrayComparatorInstalledDatTests</c>' fixture 1
|
||||
/// exercises for this exact Setup) plus
|
||||
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>. The entity
|
||||
/// id now MUST match the projection's
|
||||
/// <see cref="RenderSourceMetadata.LocalEntityId"/> — chunk 2's
|
||||
/// <see cref="WalkProductionWorldData"/> indoor sweep looks the retail
|
||||
/// CELLARRAY up BY that id, unlike the deleted method's seed+position
|
||||
/// pure function, which never carried entity identity. The former
|
||||
/// "WithoutCollisionRows" name no longer describes production: a part
|
||||
/// array is always supplied at the SAME registration as the collision
|
||||
/// shapes now (chunk 1b), so <c>GetOwnerCells</c> for this BSP-bearing
|
||||
/// Setup reflects that registration — and, per the comparator's fixture-1
|
||||
/// evidence, equals the retail CELLARRAY exactly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FacilityStairAssembly_RegisterAcross015FTo015E()
|
||||
{
|
||||
string? datDir = ResolveDatDir();
|
||||
if (datDir is null)
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
const uint setupId = 0x02000623u;
|
||||
DatSetup setup = Assert.IsType<DatSetup>(dats.Get<DatSetup>(setupId));
|
||||
PhysicsEngine engine = BuildHubEngine(dats);
|
||||
PhysicsDataCache cache = Assert.IsType<PhysicsDataCache>(engine.DataCache);
|
||||
IReadOnlyList<MeshRef> meshRefs = SetupMesh.Flatten(setup);
|
||||
foreach (MeshRef meshRef in meshRefs)
|
||||
{
|
||||
DatGfxObj gfxObj = Assert.IsType<DatGfxObj>(
|
||||
dats.Get<DatGfxObj>(meshRef.GfxObjId));
|
||||
cache.CacheGfxObj(meshRef.GfxObjId, gfxObj);
|
||||
}
|
||||
|
||||
DatEnvCell parent = Assert.IsType<DatEnvCell>(
|
||||
dats.Get<DatEnvCell>(FacilityHub | 0x015Fu));
|
||||
var stair = Assert.Single(
|
||||
parent.StaticObjects,
|
||||
staticObject =>
|
||||
staticObject.Id == setupId);
|
||||
var rootPosition = new Vector3(
|
||||
stair.Frame.Origin.X,
|
||||
stair.Frame.Origin.Y,
|
||||
stair.Frame.Origin.Z);
|
||||
Quaternion rootRotation = stair.Frame.Orientation;
|
||||
|
||||
List<ShadowShape> bspShapes = ShadowShapeBuilder.FromLandblockBspParts(
|
||||
meshRefs, isBuildingShell: false, cache.GetGfxObj);
|
||||
List<ShadowShape> parts = ShadowShapeBuilder.FromStaticRenderParts(
|
||||
meshRefs,
|
||||
cache.GetGfxObj,
|
||||
cache.GetVisualBounds,
|
||||
out bool hasPhysicsBsp);
|
||||
Assert.True(hasPhysicsBsp);
|
||||
Assert.NotEmpty(parts);
|
||||
Assert.NotEmpty(bspShapes);
|
||||
|
||||
const uint entityId = 0x48A02000u;
|
||||
engine.ShadowObjects.RegisterMultiPart(
|
||||
entityId,
|
||||
rootPosition,
|
||||
rootRotation,
|
||||
bspShapes,
|
||||
state: 0u,
|
||||
flags: EntityCollisionFlags.None,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: FacilityHub,
|
||||
seedCellId: FacilityHub | 0x015Fu,
|
||||
isStatic: true,
|
||||
partArray: parts);
|
||||
|
||||
Assert.True(engine.ShadowObjects.TryGetRetailCellArray(
|
||||
entityId, out IReadOnlyList<uint> cells));
|
||||
|
||||
ShadowShape part = parts[0];
|
||||
ShadowPartBox box = ShadowPartBox.FromShape(
|
||||
part,
|
||||
rootPosition,
|
||||
rootRotation);
|
||||
LoadedCell rootCell = CornerFloodReplayTests.LoadCell(
|
||||
dats,
|
||||
FacilityHub | 0x015Fu);
|
||||
box.RefitToLocal(rootCell.InverseWorldTransform, out Vector3 boxMin, out Vector3 boxMax);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"stair root=({rootPosition.X:F3},{rootPosition.Y:F3},{rootPosition.Z:F3}) localBox=({boxMin.X:F3},{boxMin.Y:F3},{boxMin.Z:F3})..({boxMax.X:F3},{boxMax.Y:F3},{boxMax.Z:F3}) cells=[{string.Join(',', cells.Select(static id => $"0x{id:X8}"))}]"));
|
||||
|
||||
Assert.Contains(FacilityHub | 0x015Fu, cells);
|
||||
Assert.Contains(FacilityHub | 0x015Eu, cells);
|
||||
|
||||
// The registration carries collision shapes at the SAME transaction
|
||||
// now (production always does — chunk 1b threaded the part array
|
||||
// through the SAME RegisterMultiPart call the collision dispatch
|
||||
// uses), so GetOwnerCells reflects that registration instead of
|
||||
// staying empty; retail == collision for this BSP-bearing Setup
|
||||
// (RetailCellArrayComparatorInstalledDatTests fixture 1), so the two
|
||||
// sets agree exactly.
|
||||
Assert.Equal(
|
||||
cells.OrderBy(id => id),
|
||||
engine.ShadowObjects.GetOwnerCells(entityId).OrderBy(id => id));
|
||||
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
RenderProjectionId projectionId = RenderProjectionId.FromRaw(entityId);
|
||||
RenderTransform transform = RenderTransform.FromRoot(
|
||||
rootPosition,
|
||||
rootRotation,
|
||||
1f);
|
||||
RenderProjectionRecord projection = new RenderProjectionRecord() with
|
||||
{
|
||||
Id = projectionId,
|
||||
ProjectionClass = RenderProjectionClass.IndoorCellStatic,
|
||||
OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
|
||||
Transform = transform,
|
||||
PreviousTransform = new PreviousRenderTransform(transform.LocalToWorld),
|
||||
Residency = new RenderSpatialResidency(
|
||||
RenderSpatialBucket.FromRaw(FacilityHub | 0x015Fu),
|
||||
FacilityHub,
|
||||
FacilityHub | 0x015Fu),
|
||||
Flags = RenderProjectionFlags.Draw,
|
||||
Source = new RenderSourceMetadata() with
|
||||
{
|
||||
LocalEntityId = entityId,
|
||||
SourceId = setupId,
|
||||
ParentCellId = FacilityHub | 0x015Fu,
|
||||
},
|
||||
EntityPayload = new RenderEntityPayload(meshRefs, null, false),
|
||||
};
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
|
||||
|
||||
var worldData = new WalkProductionWorldData(
|
||||
new WalkBuildingRegistry(),
|
||||
engine.ShadowObjects);
|
||||
worldData.BeginFrame(
|
||||
scene.OpenQuery(),
|
||||
FacilityHub,
|
||||
renderCenterLbX: 0x8A,
|
||||
renderCenterLbY: 0x02);
|
||||
|
||||
Assert.Contains(
|
||||
worldData.GetCellStatics(FacilityHub | 0x015Eu).Records,
|
||||
record => record.Id == projectionId);
|
||||
Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 re-diagnosis (fix#1 failed the visual gate): the vanish flips on a SLIGHT turn +
|
||||
/// depends on zoom, so it is a gaze/eye knife-edge, not the eye-squarely-in-opening case
|
||||
/// fix#1 handled. Drive the REAL camera at a fixed player spot on the 015F stairs, sweep
|
||||
/// yaw FINELY at two zooms, and print the viewer cell + stair-cell admission each step to
|
||||
/// locate the knife-edge and which cell flips.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_RealStaircase_FineYawZoomSweep_FindKnifeEdge()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
uint[] stair = { 0x015Eu, 0x015Fu, 0x01C1u, 0x01C0u, 0x020Fu };
|
||||
// Player standing mid-015F stairs, eye near the ceiling boundary (per the sweep char).
|
||||
foreach (var playerPos in new[] { new Vector3(60.5f, -46f, -2.5f), new Vector3(60.5f, -47f, -3.0f) })
|
||||
foreach (float dist in new[] { 2.61f, 12.0f })
|
||||
{
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"=== player=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1}) zoom={dist} ==="));
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist,
|
||||
};
|
||||
uint prevViewer = 0; string prevAdmit = "";
|
||||
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 6f)
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f;
|
||||
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Fu, playerPos + new Vector3(0, 0, 0.1f));
|
||||
for (int i = 0; i < 160; i++)
|
||||
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
|
||||
var eye = cam.Position; uint viewer = cam.ViewerCellId;
|
||||
string admit = "?";
|
||||
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rc))
|
||||
{
|
||||
var f = PortalVisibilityBuilder.Build(rc, eye, lookup, cam.View * cam.Projection, null);
|
||||
var s = f.OrderedVisibleCells;
|
||||
admit = string.Concat(stair.Select(low => (s.Contains(FacilityHub | low) ? "1" : "0")));
|
||||
}
|
||||
// Print only when viewer or the admit pattern CHANGES (knife-edges).
|
||||
if (viewer != prevViewer || admit != prevAdmit)
|
||||
{
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} stair[5E,5F,C1,C0,0F]={admit}"));
|
||||
prevViewer = viewer; prevAdmit = admit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 retail-vs-us flood DEPTH: retail's cdb capture shows PView cell_draw_num reaching 26
|
||||
/// cells from camera cell 015E (avg 14.3) looking down the spiral shaft. Sweep OUR real camera
|
||||
/// from 015E over many gaze/zoom poses and report the max/avg OrderedVisibleCells — if ours
|
||||
/// caps well below retail's 26, our flood truncates the spiral (the vanish).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_FloodDepthFrom015E_VsRetail26()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
int maxCells = 0; uint bestViewer = 0; double sum = 0; int n = 0; string bestSet = "";
|
||||
foreach (var playerPos in new[] { new Vector3(61f, -42f, -4f), new Vector3(60f, -41f, -5f), new Vector3(62f, -43f, -4f) })
|
||||
foreach (float dist in new[] { 2.61f, 4f, 8f })
|
||||
foreach (float pitch in new[] { 0.291f, 0.7f, -0.2f })
|
||||
{
|
||||
var cam = new RetailChaseCamera { CollisionProbe = new PhysicsCameraCollisionProbe(engine), FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist, Pitch = pitch };
|
||||
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 10f)
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f;
|
||||
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Eu, playerPos + new Vector3(0, 0, 0.1f));
|
||||
for (int i = 0; i < 140; i++) cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
|
||||
uint viewer = cam.ViewerCellId;
|
||||
if (viewer == 0u || !renderCells.TryGetValue(viewer, out var rc)) continue;
|
||||
var f = PortalVisibilityBuilder.Build(rc, cam.Position, lookup, cam.View * cam.Projection, null);
|
||||
int c = f.OrderedVisibleCells.Count; sum += c; n++;
|
||||
if (c > maxCells) { maxCells = c; bestViewer = viewer; bestSet = CellSet(f.OrderedVisibleCells.OrderBy(v => v)); }
|
||||
}
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"OUR flood from 015E-area: maxCells={maxCells} (retail max=26) avg={sum / Math.Max(1, n):F1} (retail avg=14.3) bestViewer=0x{bestViewer & 0xFFFF:X4}"));
|
||||
_out.WriteLine($" best set: [{bestSet}]");
|
||||
}
|
||||
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
|
||||
}
|
||||
|
||||
private static Matrix4x4 ViewProj(Vector3 eye, Vector3 dir)
|
||||
=> Matrix4x4.CreateLookAt(eye, eye + Vector3.Normalize(dir), Vector3.UnitZ)
|
||||
* Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
|
||||
|
||||
// Signed distance of a WORLD point to a render cell's portal[i] plane (local-space plane).
|
||||
private static float PortalPlaneDistance(LoadedCell cell, int i, Vector3 worldPt)
|
||||
{
|
||||
var pl = cell.ClipPlanes[i];
|
||||
var local = Vector3.Transform(worldPt, cell.InverseWorldTransform);
|
||||
return Vector3.Dot(pl.Normal, local) + pl.D;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 THE decisive experiment. Reproduce the captured failing frame — camera in
|
||||
/// cell 0x015F with the eye sitting IN the ceiling-portal plane (production [flap]:
|
||||
/// root=015F eye=(60.47,-46.57,-0.00), p->01C1 clip=0) — and confirm the upper
|
||||
/// stair cell 01C1 drops from the flood. Then move the SAME eye 0.4 m off that
|
||||
/// plane (into the cell interior) and confirm 01C1 is re-admitted. If the contrast
|
||||
/// holds, #177's fix is a viewpoint-off-the-portal-plane rule, and the load-bearing
|
||||
/// clip (#119/#181) is correct as-is.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EdgeOnCeilingPortal_DropsUpperCell_OffPlaneReadmits()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var root = cells[FacilityHub | 0x015Fu];
|
||||
uint upperId = FacilityHub | 0x01C1u;
|
||||
|
||||
// Find 015F's portal to 01C1 (the ceiling/up portal) + report its normal.
|
||||
int upIdx = -1;
|
||||
for (int i = 0; i < root.Portals.Count; i++)
|
||||
if (root.Portals[i].OtherCellId == 0x01C1u) { upIdx = i; break; }
|
||||
Assert.True(upIdx >= 0, "015F has no portal to 01C1");
|
||||
var upN = root.ClipPlanes[upIdx].Normal;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"015F portal[{upIdx}]->01C1 localN=({upN.X:F2},{upN.Y:F2},{upN.Z:F2})"));
|
||||
|
||||
// Look UP the stairs so the ceiling portal + 01C1 land on screen.
|
||||
var gaze = new Vector3(0f, -0.3f, 1f);
|
||||
|
||||
// Captured FAILING eye — in the ceiling-portal plane (world z ~0).
|
||||
var eyeEdgeOn = new Vector3(60.47f, -46.57f, -0.00f);
|
||||
// Same eye, pulled 0.4 m DOWN (off the ceiling portal, into 015F's interior).
|
||||
var eyeOffPlane = eyeEdgeOn + new Vector3(0f, 0f, -0.40f);
|
||||
|
||||
foreach (var (label, eye) in new[] { ("edge-on(captured)", eyeEdgeOn), ("off-plane(-0.4z)", eyeOffPlane) })
|
||||
{
|
||||
float d = PortalPlaneDistance(root, upIdx, eye);
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
root, eye, lookup, ViewProj(eye, gaze),
|
||||
buildingMembership: null);
|
||||
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
|
||||
bool up = vis.Contains(upperId);
|
||||
string admit = up ? "ADMITTED" : "DROPPED";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"{label,-18} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) D->01C1={d,6:F3} 01C1={admit} flood={vis.Count} [{CellSet(vis)}]"));
|
||||
}
|
||||
|
||||
// #177 CHARACTERIZATION (fix#1 reverted — this edge-on-in-plane case was NOT the
|
||||
// production mechanism; the real vanish is a grazing/sliver flood collapse in the
|
||||
// spiral, root 0x01C8, portals off-screen/sliver, camera NOT collision-jammed).
|
||||
// Kept as a mechanism pin: an eye exactly in the ceiling-portal plane drops the upper
|
||||
// cell (edge-on → <3 clip), an eye 0.4 m off admits it via the normal clip.
|
||||
var fEdge = PortalVisibilityBuilder.Build(root, eyeEdgeOn, lookup, ViewProj(eyeEdgeOn, gaze), null);
|
||||
var fOff = PortalVisibilityBuilder.Build(root, eyeOffPlane, lookup, ViewProj(eyeOffPlane, gaze), null);
|
||||
Assert.DoesNotContain(upperId, fEdge.OrderedVisibleCells); // edge-on drops (mechanism)
|
||||
Assert.Contains(upperId, fOff.OrderedVisibleCells); // off-plane admits (control)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #177 sweep characterization: place the player on the 015F staircase and drive the
|
||||
/// REAL chase camera + collision sweep, reporting where the eye lands relative to the
|
||||
/// 015F ceiling-portal plane (world z=0). If the eye rests AT the plane (|D|~0) the
|
||||
/// flood collapses (see EdgeOnCeilingPortal test); this shows whether that is a swept
|
||||
/// contact leaving the eye on the surface vs free-space boom geometry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_StaircaseSweep_EyeClearanceFromCeilingPortal()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
var root015F = renderCells[FacilityHub | 0x015Fu];
|
||||
int upIdx = 0; // portal[0]->01C1 (ceiling)
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
// Player on the 015F stairs (world y~-46, ceiling portal at world z~0), swept
|
||||
// at a range of heights + facings (up-stairs = -Y, down-stairs = +Y).
|
||||
foreach (float pz in new[] { -1.0f, -1.5f, -2.0f, -2.5f, -3.0f, -3.5f })
|
||||
foreach (var (fLabel, yaw) in new (string, float)[] { ("up(-Y)", -MathF.PI / 2f), ("down(+Y)", MathF.PI / 2f) })
|
||||
{
|
||||
var playerPos = new Vector3(60.5f, -46f, pz);
|
||||
var (pcell, _) = engine.AdjustPosition(FacilityHub | 0x015Fu, playerPos + new Vector3(0, 0, 0.1f));
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f, Aspect = 16f / 9f,
|
||||
};
|
||||
for (int i = 0; i < 300; i++)
|
||||
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, pcell, 0x5000000A);
|
||||
|
||||
var eye = cam.Position;
|
||||
uint viewer = cam.ViewerCellId;
|
||||
// D from the eye to 015F's ceiling-portal plane (only meaningful if viewer==015F).
|
||||
float dCeil = PortalPlaneDistance(root015F, upIdx, eye);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"playerZ={pz,5:F1} face={fLabel,-8} pcell=0x{pcell & 0xFFFF:X4} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} D->ceilPortal={dCeil,6:F3}"));
|
||||
}
|
||||
}
|
||||
finally { CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA; }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
// spot label, player x, player cell, description
|
||||
[InlineData(90.0f, 0x0178u, "TOP corridor 0178 (approach view)")]
|
||||
[InlineData(100.0f, 0x0183u, "BOTTOM room 0183 (look-back view)")]
|
||||
public void Diagnostic_ParkedYawZoomSweep_StairAdmission(float px, uint pcell, string label)
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool sC = CameraDiagnostics.CollideCamera; bool sA = CameraDiagnostics.AlignToSlope;
|
||||
float sT = CameraDiagnostics.TranslationStiffness, sR = CameraDiagnostics.RotationStiffness;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true; CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f; CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
var playerPos = new Vector3(px, -40f, FloorZ(px));
|
||||
uint playerCell = FacilityHub | pcell;
|
||||
_out.WriteLine($"=== {label} @ ({px:F1},-40,{FloorZ(px):F2}) ===");
|
||||
foreach (float dist in new[] { 2.61f, 8.0f })
|
||||
{
|
||||
_out.WriteLine($" -- zoom Distance={dist} --");
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f, Aspect = 16f / 9f, Distance = dist,
|
||||
};
|
||||
for (float yawDeg = 0f; yawDeg < 360f; yawDeg += 30f)
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f;
|
||||
// Settle at this yaw (static player).
|
||||
for (int i = 0; i < 200; i++)
|
||||
cam.Update(playerPos, yaw, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f, playerCell, 0x5000000A);
|
||||
Vector3 eye = cam.Position; uint viewer = cam.ViewerCellId;
|
||||
var vp = cam.View * cam.Projection;
|
||||
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell))
|
||||
{
|
||||
var f = PortalVisibilityBuilder.Build(rootCell, eye, lookup, vp, null);
|
||||
var vis = f.OrderedVisibleCells.OrderBy(v => v).ToList();
|
||||
string flags = $"0178={(vis.Contains(FacilityHub | 0x0178u) ? "Y" : "-")} 0182={(vis.Contains(FacilityHub | 0x0182u) ? "Y" : "-")} 0183={(vis.Contains(FacilityHub | 0x0183u) ? "Y" : "-")} 0181={(vis.Contains(FacilityHub | 0x0181u) ? "Y" : "-")}";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer & 0xFFFF:X4} {flags} flood={vis.Count} [{CellSet(vis)}]"));
|
||||
}
|
||||
else
|
||||
_out.WriteLine(FormattableString.Invariant($" yaw={yawDeg,3:F0} eye=({eye.X,6:F2},{eye.Y,6:F2},{eye.Z,6:F2}) viewer=0x{viewer:X8} ROOT-MISSING"));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = sC; CameraDiagnostics.AlignToSlope = sA;
|
||||
CameraDiagnostics.TranslationStiffness = sT; CameraDiagnostics.RotationStiffness = sR;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_Descent_RealCameraSweep_StairCellRetention()
|
||||
{
|
||||
var datDir = ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
var renderCells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
var engine = BuildHubEngine(dats);
|
||||
Func<uint, LoadedCell?> lookup = id => renderCells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
bool savedColl = CameraDiagnostics.CollideCamera;
|
||||
bool savedAlign = CameraDiagnostics.AlignToSlope;
|
||||
float savedT = CameraDiagnostics.TranslationStiffness;
|
||||
float savedR = CameraDiagnostics.RotationStiffness;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = true;
|
||||
CameraDiagnostics.AlignToSlope = true;
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
var cam = new RetailChaseCamera
|
||||
{
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||
FovY = MathF.PI / 3f,
|
||||
Aspect = 16f / 9f,
|
||||
};
|
||||
|
||||
const float yaw = 0f; // facing +X, descending
|
||||
const float speed = 3.0f; // m/s run
|
||||
const float dt = 1f / 60f;
|
||||
float startX = 88f, endX = 101.5f;
|
||||
|
||||
// Seat the player + settle the camera at the start pose.
|
||||
uint playerCell = FacilityHub | 0x0178u;
|
||||
Vector3 P(float x) => new Vector3(x, -40f, FloorZ(x));
|
||||
for (int i = 0; i < 400; i++)
|
||||
cam.Update(P(startX), yaw, Vector3.Zero, true, Vector3.UnitZ, dt, playerCell, 0x5000000A);
|
||||
|
||||
_out.WriteLine("x playerCell eye=(x,y,z) viewerCell coh flood stairs rootPortalsPastPlane");
|
||||
float x = startX;
|
||||
uint prevViewer = cam.ViewerCellId;
|
||||
int collapses = 0, incoherentFrames = 0;
|
||||
while (x <= endX)
|
||||
{
|
||||
var playerPos = P(x);
|
||||
// Track player membership like production: AdjustPosition carry-forward.
|
||||
var (pc, found) = engine.AdjustPosition(playerCell, playerPos + new Vector3(0, 0, 0.1f));
|
||||
if (found) playerCell = pc;
|
||||
|
||||
cam.Update(playerPos, yaw, new Vector3(speed, 0, 0), true, Vector3.UnitZ, dt, playerCell, 0x5000000A);
|
||||
|
||||
Vector3 eye = cam.Position;
|
||||
uint viewer = cam.ViewerCellId;
|
||||
var viewProj = cam.View * cam.Projection;
|
||||
|
||||
string floodStr, stairs, coh, past;
|
||||
if (viewer != 0u && renderCells.TryGetValue(viewer, out var rootCell))
|
||||
{
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
rootCell, eye, lookup, viewProj,
|
||||
buildingMembership: null);
|
||||
var vis = frame.OrderedVisibleCells.OrderBy(v => v).ToList();
|
||||
floodStr = $"{vis.Count,2} [{CellSet(vis)}]";
|
||||
bool ramp = vis.Contains(FacilityHub | 0x0182u);
|
||||
bool lower = vis.Contains(FacilityHub | 0x0183u);
|
||||
stairs = $"r{(ramp ? "Y" : "-")}l{(lower ? "Y" : "-")}";
|
||||
|
||||
// Coherence: is the eye on the interior side of ALL of root's portals?
|
||||
// If not, the eye is outside root -> the crossed portal's subtree drops.
|
||||
var pastList = new List<string>();
|
||||
bool coherent = true;
|
||||
for (int i = 0; i < rootCell.Portals.Count && i < rootCell.ClipPlanes.Count; i++)
|
||||
{
|
||||
if (!EyeInteriorSide(rootCell, i, eye))
|
||||
{
|
||||
coherent = false;
|
||||
pastList.Add($"p{i}->0x{rootCell.Portals[i].OtherCellId:X4}(D={SideD(rootCell, i, eye):F3})");
|
||||
}
|
||||
}
|
||||
coh = coherent ? "IN " : "OUT";
|
||||
past = pastList.Count == 0 ? "-" : string.Join(",", pastList);
|
||||
if (!coherent) incoherentFrames++;
|
||||
if (vis.Count <= 1) collapses++;
|
||||
}
|
||||
else
|
||||
{
|
||||
floodStr = "root-missing/0"; stairs = "--"; coh = "?"; past = $"viewer=0x{viewer:X8}";
|
||||
}
|
||||
|
||||
bool viewerFlip = viewer != prevViewer;
|
||||
prevViewer = viewer;
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"{x,6:F2} 0x{playerCell:X8} ({eye.X,7:F3},{eye.Y,7:F3},{eye.Z,7:F3}) 0x{viewer:X8} {coh} {floodStr} {stairs} {(viewerFlip ? "FLIP " : "")}{past}"));
|
||||
|
||||
x += speed * dt;
|
||||
}
|
||||
_out.WriteLine($"SUMMARY: collapses(flood<=1)={collapses} incoherentFrames(eye-outside-root)={incoherentFrames}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.CollideCamera = savedColl;
|
||||
CameraDiagnostics.AlignToSlope = savedAlign;
|
||||
CameraDiagnostics.TranslationStiffness = savedT;
|
||||
CameraDiagnostics.RotationStiffness = savedR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #181 — the portal-flood admitted set flaps 31↔32 on MICRON eye noise at a
|
||||
/// parked camera (live: launch-176-leakfix.log, root 0x8A020142, vis flips
|
||||
/// every ~100–200 frames for 517k frames; the swept eye carries ~7 µm
|
||||
/// float-roundtrip noise). The flapping cell's visibility-scoped lights + the
|
||||
/// union-AABB scissor rect strobe = the #176 washed-region flicker.
|
||||
///
|
||||
/// This replay hunts the knife edge headlessly: at the live parked eye
|
||||
/// (49.15, −38.62, −3.98), sweep gazes and perturb the eye by ±0.5 mm per
|
||||
/// axis; ANY admitted-set difference at sub-mm perturbation is the #181
|
||||
/// instability, and the symmetric difference names the flapping cell.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue181VisFlapReplayTests
|
||||
{
|
||||
private const uint FacilityHub = 0x8A020000u;
|
||||
private const uint LiveRoot = FacilityHub | 0x0142u;
|
||||
|
||||
private static readonly Vector3 LiveEye = new(49.15f, -38.62f, -3.98f);
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue181VisFlapReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static List<uint> Flood(
|
||||
Dictionary<uint, LoadedCell> cells, uint rootId, Vector3 eye, Vector3 gazeDir)
|
||||
{
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir),
|
||||
buildingMembership: null);
|
||||
var result = new List<uint>(frame.OrderedVisibleCells);
|
||||
result.Sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string CellSetString(IEnumerable<uint> ids)
|
||||
=> string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}"));
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_FlappingCellViewRegion_SliverOrLarge()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
uint flapper = FacilityHub | 0x0181u;
|
||||
|
||||
// The most unstable pose from the perturbation sweep.
|
||||
float yaw = 15f * MathF.PI / 180f, pitch = -20f * MathF.PI / 180f;
|
||||
var gaze = new Vector3(
|
||||
MathF.Cos(pitch) * MathF.Cos(yaw),
|
||||
MathF.Cos(pitch) * MathF.Sin(yaw),
|
||||
MathF.Sin(pitch));
|
||||
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
foreach (var (label, eye) in new (string, Vector3)[]
|
||||
{
|
||||
("base ", LiveEye),
|
||||
("+0.5mm x ", LiveEye + new Vector3(0.0005f, 0, 0)),
|
||||
("-0.5mm x ", LiveEye - new Vector3(0.0005f, 0, 0)),
|
||||
("+5mm x ", LiveEye + new Vector3(0.005f, 0, 0)),
|
||||
("-5mm x ", LiveEye - new Vector3(0.005f, 0, 0)),
|
||||
("+2cm z ", LiveEye + new Vector3(0, 0, 0.02f)),
|
||||
("-2cm z ", LiveEye - new Vector3(0, 0, 0.02f)),
|
||||
})
|
||||
{
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cells[LiveRoot], eye, lookup, ViewProjFor(eye, gaze),
|
||||
buildingMembership: null);
|
||||
|
||||
if (!frame.CellViews.TryGetValue(flapper, out var view) || view.IsEmpty)
|
||||
{
|
||||
_out.WriteLine($"{label}: 0181 NOT ADMITTED");
|
||||
continue;
|
||||
}
|
||||
|
||||
// NDC shoelace area of the admitted region (screen is 4.0 NDC-units total).
|
||||
float area = 0f; int polys = 0, verts = 0;
|
||||
foreach (var vp in view.Polygons)
|
||||
{
|
||||
var v = vp.Vertices;
|
||||
polys++; verts += v.Length;
|
||||
float a = 0f;
|
||||
for (int i = 0; i < v.Length; i++)
|
||||
{
|
||||
var p = v[i]; var q = v[(i + 1) % v.Length];
|
||||
a += p.X * q.Y - q.X * p.Y;
|
||||
}
|
||||
area += MathF.Abs(a) * 0.5f;
|
||||
}
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"{label}: 0181 admitted polys={polys} verts={verts} ndcArea={area:F4} ({area / 4f * 100f:F1}% of screen)"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_SubMillimeterEyePerturbation_MustNotChangeAdmission()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub);
|
||||
Assert.True(cells.ContainsKey(LiveRoot), "live root 0x0142 not loaded");
|
||||
|
||||
// ±0.5 mm per axis — an order of magnitude above the live µm noise, well
|
||||
// below anything visually meaningful.
|
||||
var perturbations = new[]
|
||||
{
|
||||
new Vector3( 0.0005f, 0, 0), new Vector3(-0.0005f, 0, 0),
|
||||
new Vector3(0, 0.0005f, 0), new Vector3(0, -0.0005f, 0),
|
||||
new Vector3(0, 0, 0.0005f), new Vector3(0, 0, -0.0005f),
|
||||
};
|
||||
|
||||
int unstableGazes = 0;
|
||||
for (int yawDeg = 0; yawDeg < 360; yawDeg += 15)
|
||||
{
|
||||
foreach (float pitchDeg in new[] { 0f, -20f, -35f })
|
||||
{
|
||||
float yaw = yawDeg * MathF.PI / 180f, pitch = pitchDeg * MathF.PI / 180f;
|
||||
var gaze = new Vector3(
|
||||
MathF.Cos(pitch) * MathF.Cos(yaw),
|
||||
MathF.Cos(pitch) * MathF.Sin(yaw),
|
||||
MathF.Sin(pitch));
|
||||
|
||||
var baseline = Flood(cells, LiveRoot, LiveEye, gaze);
|
||||
foreach (var d in perturbations)
|
||||
{
|
||||
var perturbed = Flood(cells, LiveRoot, LiveEye + d, gaze);
|
||||
if (perturbed.SequenceEqual(baseline)) continue;
|
||||
|
||||
unstableGazes++;
|
||||
var removed = baseline.Except(perturbed).ToList();
|
||||
var added = perturbed.Except(baseline).ToList();
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"UNSTABLE yaw={yawDeg} pitch={pitchDeg} d=({d.X * 1000:F1},{d.Y * 1000:F1},{d.Z * 1000:F1})mm base={baseline.Count} pert={perturbed.Count} removed=[{CellSetString(removed)}] added=[{CellSetString(added)}]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"unstable (gaze, perturbation) pairs: {unstableGazes}");
|
||||
// Diagnostic first: report, don't assert — the fix turns this into a hard pin.
|
||||
}
|
||||
}
|
||||
|
|
@ -155,29 +155,12 @@ public class Issue181WallPressEquilibriumTests
|
|||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"viewer cells seen: {cells.Count} transitions={cellTransitions} eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6}) cell=0x{cam.ViewerCellId:X8}"));
|
||||
|
||||
// Orbit structure: 16 consecutive frames at 6dp, with the sweep's
|
||||
// own [flap-sweep] lines captured for the same frames.
|
||||
bool savedFlap = RenderingDiagnostics.ProbeFlapEnabled;
|
||||
var savedOut = Console.Out;
|
||||
try
|
||||
// Orbit structure: 16 consecutive frames at 6dp.
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
RenderingDiagnostics.ProbeFlapEnabled = true;
|
||||
using var writer = new StringWriter();
|
||||
Console.SetOut(writer);
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
Step();
|
||||
writer.WriteLine(FormattableString.Invariant(
|
||||
$"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})"));
|
||||
}
|
||||
Console.SetOut(savedOut);
|
||||
foreach (var line in writer.ToString().Split('\n'))
|
||||
if (line.Length > 1) _out.WriteLine(line.TrimEnd());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(savedOut);
|
||||
RenderingDiagnostics.ProbeFlapEnabled = savedFlap;
|
||||
Step();
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})"));
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes
|
||||
/// WB-DIAG to ~9.1M instances/frame. Suspected cause: <see cref="PortalVisibilityBuilder.Build"/>
|
||||
/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding
|
||||
/// (decomp:311878). A dungeon cell has <c>seen_outside==0</c>; retail's PVS for it is just the
|
||||
/// cell's <c>stab_list</c> (<see cref="LoadedCell.VisibleCells"/>) — typically a small bounded
|
||||
/// set. If our flood instead visits ~all cells of the landblock, that is the blowup.
|
||||
///
|
||||
/// This is a DIAGNOSTIC, not a fix: it loads the real 0x0007 interior cells, runs the real
|
||||
/// production flood from representative dungeon-cell roots, and PRINTS the ground-truth numbers —
|
||||
/// flood visited-cell-set size (<see cref="PortalVisibilityFrame.OrderedVisibleCells"/>) vs the
|
||||
/// root's stab_list size (<see cref="LoadedCell.VisibleCells"/>), plus how many visited cells
|
||||
/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class Issue95DungeonFloodDiagnosticTests
|
||||
{
|
||||
private const uint TownNetwork = 0x00070000u;
|
||||
|
||||
private readonly ITestOutputHelper _out;
|
||||
public Issue95DungeonFloodDiagnosticTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
// Production-ish projection (mirrors the sibling harnesses): FovY ~1.2, 1280x720,
|
||||
// near 0.1, far 5000. The flood's clip is near-independent, so exactness is not
|
||||
// load-bearing for cell-count measurement.
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Measure_DungeonFlood_VisibleCellCount()
|
||||
{
|
||||
var datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
if (datDir is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: dat dir did not resolve (ACDREAM_DAT_DIR unset and "
|
||||
+ "%USERPROFILE%\\Documents\\Asheron's Call absent). No numbers measured.");
|
||||
// Diagnostic test: do not hard-fail when dats are absent (matches sibling harnesses).
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
}
|
||||
_out.WriteLine($"dat dir resolved: {datDir}");
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// 1) LandBlockInfo header — NumCells for 0x0007.
|
||||
var lbi = dats.Get<DatLandBlockInfo>(TownNetwork | 0xFFFEu);
|
||||
if (lbi is null)
|
||||
{
|
||||
_out.WriteLine($"SKIP: LandBlockInfo 0x{TownNetwork | 0xFFFEu:X8} not found in the dat "
|
||||
+ "(0x0007 may not exist in this client_cell_1.dat).");
|
||||
return;
|
||||
}
|
||||
_out.WriteLine($"=== 0x0007 (Town Network) LandBlockInfo ===");
|
||||
_out.WriteLine($"NumCells (DatLandBlockInfo.NumCells) = {lbi.NumCells}");
|
||||
|
||||
// 2) Load ALL interior cells (sparse ids tolerated — see LoadAllInteriorCells).
|
||||
var loaded = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, TownNetwork);
|
||||
_out.WriteLine($"cells actually loaded = {loaded.Count}");
|
||||
Assert.True(loaded.Count > 0, "no interior cells loaded for 0x0007 — cannot measure");
|
||||
|
||||
Func<uint, LoadedCell?> lookup = id => loaded.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// 3) Per-cell stab_list (VisibleCells) distribution across ALL loaded cells.
|
||||
// This is the bounded retail PVS size we expect the flood to roughly match.
|
||||
var stabSizes = loaded.Values.Select(c => c.VisibleCells.Count).ToList();
|
||||
int seenOutsideCount = loaded.Values.Count(c => c.SeenOutside);
|
||||
int interiorCount = loaded.Count - seenOutsideCount;
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("=== stab_list (LoadedCell.VisibleCells) distribution over ALL loaded cells ===");
|
||||
_out.WriteLine($"cells with SeenOutside==true (entrance/exterior-facing) = {seenOutsideCount}");
|
||||
_out.WriteLine($"cells with SeenOutside==false (interior dungeon) = {interiorCount}");
|
||||
if (stabSizes.Count > 0)
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"VisibleCells.Count min={stabSizes.Min()} max={stabSizes.Max()} avg={stabSizes.Average():F1} sum={stabSizes.Sum()}"));
|
||||
int emptyStab = stabSizes.Count(s => s == 0);
|
||||
_out.WriteLine($"cells with EMPTY stab_list (no dat PVS) = {emptyStab}");
|
||||
|
||||
// 4) Pick representative DUNGEON roots: the first interior (SeenOutside==false) cells in
|
||||
// ascending id order. If none exist, fall back to 0x00070100 and report that.
|
||||
var interiorRoots = loaded
|
||||
.Where(kv => !kv.Value.SeenOutside)
|
||||
.OrderBy(kv => kv.Key)
|
||||
.Select(kv => kv.Value)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
if (interiorRoots.Count == 0)
|
||||
{
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("NOTE: NO cell has SeenOutside==false (all cells see the exterior). "
|
||||
+ "Falling back to root 0x00070100 for the flood measurement.");
|
||||
if (loaded.TryGetValue(TownNetwork | 0x0100u, out var fallback))
|
||||
interiorRoots.Add(fallback);
|
||||
else
|
||||
{
|
||||
_out.WriteLine("WARN: 0x00070100 not loaded either; using the lowest-id loaded cell.");
|
||||
interiorRoots.Add(loaded.OrderBy(kv => kv.Key).First().Value);
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("=== PER-ROOT FLOOD MEASUREMENT (PortalVisibilityBuilder.Build) ===");
|
||||
_out.WriteLine("property read for the visited-cell set: PortalVisibilityFrame.OrderedVisibleCells");
|
||||
_out.WriteLine("root | seenOut | stab(VisibleCells) | flood(OrderedVisibleCells) | crossLB | dir");
|
||||
|
||||
var floodSizes = new List<int>();
|
||||
foreach (var root in interiorRoots)
|
||||
{
|
||||
// Eye at the root cell's world origin, looking toward its first portal (or +X if none),
|
||||
// so the flood actually fires through an opening. Sweep all 6 axis directions and KEEP
|
||||
// the maximum visited-set — the blowup is a worst-case-over-orientation quantity.
|
||||
var eye = root.WorldPosition;
|
||||
int bestFlood = -1;
|
||||
string bestDir = "?";
|
||||
int bestCrossLb = -1;
|
||||
List<uint>? bestVisited = null;
|
||||
|
||||
// Direction candidates: toward each portal's polygon centroid (the natural look-through),
|
||||
// plus the 6 cardinal axes as a fallback sweep.
|
||||
var lookTargets = new List<(Vector3 target, string label)>();
|
||||
for (int pi = 0; pi < root.Portals.Count && pi < root.PortalPolygons.Count; pi++)
|
||||
{
|
||||
var poly = root.PortalPolygons[pi];
|
||||
if (poly is { Length: >= 1 })
|
||||
{
|
||||
var cl = Vector3.Zero;
|
||||
foreach (var v in poly) cl += v;
|
||||
cl /= poly.Length;
|
||||
lookTargets.Add((Vector3.Transform(cl, root.WorldTransform),
|
||||
$"portal{pi}->0x{root.Portals[pi].OtherCellId:X4}"));
|
||||
}
|
||||
}
|
||||
foreach (var (d, lbl) in new (Vector3, string)[]
|
||||
{
|
||||
(Vector3.UnitX, "+X"), (-Vector3.UnitX, "-X"),
|
||||
(Vector3.UnitY, "+Y"), (-Vector3.UnitY, "-Y"),
|
||||
(Vector3.UnitZ, "+Z"), (-Vector3.UnitZ, "-Z"),
|
||||
})
|
||||
lookTargets.Add((eye + d * 5f, lbl));
|
||||
|
||||
foreach (var (target, label) in lookTargets)
|
||||
{
|
||||
if (Vector3.DistanceSquared(target, eye) < 1e-6f) continue;
|
||||
var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, target));
|
||||
int floodN = frame.OrderedVisibleCells.Count;
|
||||
if (floodN > bestFlood)
|
||||
{
|
||||
bestFlood = floodN;
|
||||
bestDir = label;
|
||||
bestVisited = frame.OrderedVisibleCells;
|
||||
bestCrossLb = frame.OrderedVisibleCells.Count(id => (id & 0xFFFF0000u) != TownNetwork);
|
||||
}
|
||||
}
|
||||
|
||||
floodSizes.Add(bestFlood);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"0x{root.CellId:X8} | {(root.SeenOutside ? "Y" : "N"),5} | {root.VisibleCells.Count,18} | {bestFlood,26} | {bestCrossLb,7} | {bestDir}"));
|
||||
|
||||
// For the FIRST root, also print the actual visited set + stab set for eyeballing.
|
||||
if (ReferenceEquals(root, interiorRoots[0]) && bestVisited is not null)
|
||||
{
|
||||
_out.WriteLine(" first-root visited (OrderedVisibleCells, low ids): "
|
||||
+ string.Join(" ", bestVisited.Select(id => $"{id & 0xFFFFu:X4}")));
|
||||
_out.WriteLine(" first-root stab_list (VisibleCells, low ids): "
|
||||
+ string.Join(" ", root.VisibleCells.Select(id => $"{id & 0xFFFFu:X4}")));
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Aggregate flood-size stats across the sampled roots — the headline numbers.
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("=== AGGREGATE over sampled roots ===");
|
||||
if (floodSizes.Count > 0)
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"flood visited-set size (OrderedVisibleCells): min={floodSizes.Min()} max={floodSizes.Max()} avg={floodSizes.Average():F1} (NumCells={lbi.NumCells}, loaded={loaded.Count})"));
|
||||
var sampledStab = interiorRoots.Select(r => r.VisibleCells.Count).ToList();
|
||||
if (sampledStab.Count > 0)
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"sampled roots' stab_list size (VisibleCells): min={sampledStab.Min()} max={sampledStab.Max()} avg={sampledStab.Average():F1}"));
|
||||
_out.WriteLine("");
|
||||
_out.WriteLine("INTERPRETATION: if flood max ~= loaded.Count (visits ~all cells) while stab "
|
||||
+ "is small, that is the #95 blowup — the flood is unbounded by the retail stab_list PVS.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +1,21 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// The OUTDOOR render root (R-A2, 2026-06-08): a portal-less <see cref="LoadedCell"/> whose only job is
|
||||
/// to root the render outdoors so the flood seeds OutsideView FULL-SCREEN (terrain draws). Buildings are
|
||||
/// flooded SEPARATELY per-building (<see cref="PortalVisibilityBuilder.ConstructViewBuilding"/>), so the
|
||||
/// node carries NO reverse portals — the pre-R-A2 reverse-portal unified flood (which oscillated at the
|
||||
/// doorway) is gone. Its tests moved here; the old <c>UnifiedFloodTests</c> was retired.
|
||||
/// </summary>
|
||||
public class OutdoorCellNodeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ReturnsPortallessOutdoorRoot()
|
||||
{
|
||||
uint outdoorId = 0xA9B40031;
|
||||
var node = OutdoorCellNode.Build(outdoorId);
|
||||
LoadedCell node = OutdoorCellNode.Build(outdoorId);
|
||||
|
||||
Assert.Equal(outdoorId, node.CellId);
|
||||
Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on
|
||||
Assert.True(node.IsOutdoorNode);
|
||||
Assert.True(node.SeenOutside);
|
||||
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
|
||||
Assert.Empty(node.Portals); // R-A2: no reverse portals into buildings (buildings flood per-building)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_OutdoorRoot_SeedsFullScreenOutsideView_OnlyNodeVisible()
|
||||
{
|
||||
// The load-bearing outdoor-terrain behavior: rooting at the IsOutdoorNode node seeds OutsideView
|
||||
// with the FULL-SCREEN NDC quad, so ClipFrameAssembler yields TerrainMode != Skip and
|
||||
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery everywhere. The portal-less node
|
||||
// resolves to exactly {node} — no interior cells flooded from the root (those come per-building).
|
||||
var node = OutdoorCellNode.Build(0xA9B40031);
|
||||
|
||||
var eye = new Vector3(0, -3, 1);
|
||||
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(node, eye, _ => null, view * proj);
|
||||
|
||||
Assert.Equal(new[] { 0xA9B40031u }, frame.OrderedVisibleCells); // only the node
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
Assert.Equal(-1f, frame.OutsideView.MinX, 3);
|
||||
Assert.Equal(-1f, frame.OutsideView.MinY, 3);
|
||||
Assert.Equal(1f, frame.OutsideView.MaxX, 3);
|
||||
Assert.Equal(1f, frame.OutsideView.MaxY, 3);
|
||||
Assert.Empty(node.Portals);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,999 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class PortalVisibilityBuilderTests
|
||||
{
|
||||
private static Matrix4x4 ViewProj()
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
|
||||
{
|
||||
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
|
||||
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
|
||||
};
|
||||
|
||||
// Full-height (y in [-0.9, 0.9]) quad spanning [minX, maxX] in X at plane z.
|
||||
// Used by the multi-back-portal fixture where the X span is the only thing
|
||||
// distinguishing the LEFT and RIGHT apertures.
|
||||
private static Vector3[] QuadX(float minX, float maxX, float z) => new[]
|
||||
{
|
||||
new Vector3(minX, -0.9f, z), new Vector3(maxX, -0.9f, z),
|
||||
new Vector3(maxX, 0.9f, z), new Vector3(minX, 0.9f, z),
|
||||
};
|
||||
|
||||
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
|
||||
{
|
||||
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo>(portals),
|
||||
};
|
||||
|
||||
private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary<uint, LoadedCell> all)
|
||||
=> PortalVisibilityBuilder.Build(cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
[Fact]
|
||||
public void Build_ReusedFrame_NestedGraph_MatchesFreshAndStabilizesScratch()
|
||||
{
|
||||
var root = Cell(0x0001,
|
||||
new CellPortalInfo(0x0002, 0, 0, 0),
|
||||
new CellPortalInfo(0x0003, 1, 0, 0));
|
||||
root.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -2f));
|
||||
root.PortalPolygons.Add(QuadX(0.05f, 0.8f, -3f));
|
||||
var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
left.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -5f));
|
||||
var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
right.PortalPolygons.Add(QuadX(0.05f, 0.8f, -5f));
|
||||
var exit = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
exit.PortalPolygons.Add(QuadX(-0.8f, 0.8f, -8f));
|
||||
var cells = new Dictionary<uint, LoadedCell>
|
||||
{
|
||||
[root.CellId] = root,
|
||||
[left.CellId] = left,
|
||||
[right.CellId] = right,
|
||||
[exit.CellId] = exit,
|
||||
};
|
||||
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null;
|
||||
|
||||
PortalVisibilityFrame fresh = PortalVisibilityBuilder.Build(
|
||||
root, Vector3.Zero, Lookup, ViewProj());
|
||||
uint[] expectedOrder = fresh.OrderedVisibleCells.ToArray();
|
||||
uint[] expectedCells = fresh.CellViews.Keys.OrderBy(id => id).ToArray();
|
||||
int expectedOutsidePolygons = fresh.OutsideView.Polygons.Count;
|
||||
|
||||
var reuse = new PortalVisibilityFrame();
|
||||
PortalVisibilityBuilder.Build(root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
int scratchHighWater = reuse.ClipRegionScratchCount;
|
||||
int vertexArrayHighWater = reuse.PolygonVertexAllocationCount;
|
||||
Assert.True(scratchHighWater > 0);
|
||||
Assert.True(vertexArrayHighWater > 0);
|
||||
|
||||
for (int frame = 0; frame < 16; frame++)
|
||||
{
|
||||
PortalVisibilityFrame actual = PortalVisibilityBuilder.Build(
|
||||
root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, actual);
|
||||
Assert.Equal(expectedOrder, actual.OrderedVisibleCells);
|
||||
Assert.Equal(expectedCells, actual.CellViews.Keys.OrderBy(id => id).ToArray());
|
||||
Assert.Equal(expectedOutsidePolygons, actual.OutsideView.Polygons.Count);
|
||||
Assert.Equal(scratchHighWater, reuse.ClipRegionScratchCount);
|
||||
Assert.Equal(vertexArrayHighWater, reuse.PolygonVertexAllocationCount);
|
||||
Assert.Empty(reuse.TodoScratch);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReusedFrame_AfterLookupException_HasNoStaleWorkOrViews()
|
||||
{
|
||||
var root = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
root.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f));
|
||||
var reuse = new PortalVisibilityFrame();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => PortalVisibilityBuilder.Build(
|
||||
root,
|
||||
Vector3.Zero,
|
||||
_ => throw new InvalidOperationException("fixture lookup failure"),
|
||||
ViewProj(),
|
||||
reuseFrame: reuse));
|
||||
|
||||
var isolatedRoot = Cell(0x0010);
|
||||
PortalVisibilityFrame recovered = PortalVisibilityBuilder.Build(
|
||||
isolatedRoot, Vector3.Zero, _ => null, ViewProj(), reuseFrame: reuse);
|
||||
|
||||
Assert.Same(reuse, recovered);
|
||||
Assert.Equal(new[] { isolatedRoot.CellId }, recovered.OrderedVisibleCells);
|
||||
Assert.Equal(new[] { isolatedRoot.CellId }, recovered.CellViews.Keys);
|
||||
Assert.Empty(recovered.CrossBuildingViews);
|
||||
Assert.True(recovered.OutsideView.IsEmpty);
|
||||
Assert.Empty(reuse.TodoScratch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForBuild_DropsPathologicalContainerHighWater_AfterIdleHysteresis()
|
||||
{
|
||||
const int pathologicalCount =
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity * 4;
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var placeholder = new LoadedCell();
|
||||
|
||||
for (int i = 0; i < pathologicalCount; i++)
|
||||
{
|
||||
uint id = (uint)i + 1u;
|
||||
frame.CellViews.Add(id, new CellView());
|
||||
frame.CrossBuildingViews.Add(id, new CellView());
|
||||
frame.OrderedVisibleCells.Add(id);
|
||||
frame.QueuedScratch.Add(id);
|
||||
frame.DrawListedScratch.Add(id);
|
||||
frame.ProcessedViewCountsScratch.Add(id, i);
|
||||
frame.TodoScratch.Add((placeholder, i));
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
frame.CellViewMapCapacity
|
||||
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.True(
|
||||
frame.QueuedCapacity
|
||||
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
|
||||
frame.ResetForBuild();
|
||||
|
||||
Assert.Empty(frame.CellViews);
|
||||
Assert.Empty(frame.CrossBuildingViews);
|
||||
Assert.Empty(frame.OrderedVisibleCells);
|
||||
Assert.Empty(frame.QueuedScratch);
|
||||
Assert.Empty(frame.DrawListedScratch);
|
||||
Assert.Empty(frame.ProcessedViewCountsScratch);
|
||||
Assert.Empty(frame.TodoScratch);
|
||||
Assert.InRange(
|
||||
frame.RetainedCellViewCount,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.True(
|
||||
frame.CellViewMapCapacity
|
||||
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
|
||||
for (int i = 0; i < PortalVisibilityFrame.CapacityTrimIdleFrames; i++)
|
||||
frame.ResetForBuild();
|
||||
|
||||
Assert.InRange(
|
||||
frame.CellViewMapCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.CrossBuildingViewMapCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.QueuedCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.DrawListedCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.ProcessedViewCountCapacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.OrderedVisibleCells.Capacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
Assert.InRange(
|
||||
frame.TodoScratch.Capacity,
|
||||
0,
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
|
||||
|
||||
LoadedCell root = Cell(0x1000);
|
||||
PortalVisibilityFrame rebuilt = PortalVisibilityBuilder.Build(
|
||||
root,
|
||||
Vector3.Zero,
|
||||
_ => null,
|
||||
ViewProj(),
|
||||
reuseFrame: frame);
|
||||
|
||||
Assert.Same(frame, rebuilt);
|
||||
Assert.Equal(new[] { root.CellId }, rebuilt.OrderedVisibleCells);
|
||||
Assert.True(rebuilt.CellViews.ContainsKey(root.CellId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForBuild_RecurringLargeFlood_PreservesWarmCapacity()
|
||||
{
|
||||
const int recurringCount =
|
||||
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity + 163;
|
||||
var frame = new PortalVisibilityFrame();
|
||||
|
||||
for (int iteration = 0;
|
||||
iteration < PortalVisibilityFrame.CapacityTrimIdleFrames + 5;
|
||||
iteration++)
|
||||
{
|
||||
for (int i = 0; i < recurringCount; i++)
|
||||
{
|
||||
uint id = (uint)i + 1u;
|
||||
frame.QueuedScratch.Add(id);
|
||||
frame.OrderedVisibleCells.Add(id);
|
||||
}
|
||||
|
||||
int queuedCapacity = frame.QueuedCapacity;
|
||||
int orderedCapacity = frame.OrderedVisibleCells.Capacity;
|
||||
frame.ResetForBuild();
|
||||
|
||||
Assert.Equal(queuedCapacity, frame.QueuedCapacity);
|
||||
Assert.Equal(orderedCapacity, frame.OrderedVisibleCells.Capacity);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.1f, 1.0f, -3f)); // narrow stairwell
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
ground.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // wide window
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
float outsideWidth = frame.OutsideView.MaxX - frame.OutsideView.MinX;
|
||||
float windowOnlyWidth = PortalFrameTestHelper.ProjectedWidth(
|
||||
new[] { new Vector3(-1, 0, -6), new Vector3(1, 0, -6) }, ViewProj());
|
||||
Assert.True(outsideWidth < windowOnlyWidth * 0.5f,
|
||||
$"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet()
|
||||
{
|
||||
// Flap root-cause apparatus (2026-06-07): the live threshold flap shows OrderedVisibleCells
|
||||
// flipping 2<->6 at an eye+player identical to cm. Build is a pure static function with
|
||||
// all-fresh per-call state, so identical inputs MUST yield an identical visible set. If this
|
||||
// FAILS, the flap is a determinism bug INSIDE Build; if it PASSES (expected), the live flip is
|
||||
// sub-cm INPUT jitter and the fix must make membership robust, not Build deterministic.
|
||||
// Exercises the re-enqueue fixpoint via a diamond: 0x0004 is reached from BOTH 0x0002 and 0x0003.
|
||||
var cam = Cell(0x0001,
|
||||
new CellPortalInfo(0x0002, 0, 0, 0),
|
||||
new CellPortalInfo(0x0003, 1, 0, 0));
|
||||
cam.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -3f)); // left -> 0002
|
||||
cam.PortalPolygons.Add(QuadX(0.05f, 0.6f, -3f)); // right -> 0003
|
||||
var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
left.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -6f));
|
||||
var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0));
|
||||
right.PortalPolygons.Add(QuadX(0.05f, 0.6f, -6f));
|
||||
var back = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
back.PortalPolygons.Add(QuadX(-0.6f, 0.6f, -9f));
|
||||
var all = new Dictionary<uint, LoadedCell>
|
||||
{ [0x0001] = cam, [0x0002] = left, [0x0003] = right, [0x0004] = back };
|
||||
|
||||
var a = Build(cam, all);
|
||||
var b = Build(cam, all);
|
||||
|
||||
Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells);
|
||||
Assert.Equal(
|
||||
a.CellViews.Keys.OrderBy(k => k).ToArray(),
|
||||
b.CellViews.Keys.OrderBy(k => k).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_EyeStandingInInteriorPortal_FloodsNeighbour()
|
||||
{
|
||||
// R1 void fix (2026-06-05): when the chase camera roots in a thin doorway cell and the eye is
|
||||
// STANDING IN an interior portal opening, the live capture showed the vestibule->room portal at
|
||||
// D=0.16 m projecting to 0 verts (proj=0), so the neighbour was wrongly culled (cells=1) and
|
||||
// only the thin cell drew -> bluish void. Retail's 3D portal clip imposes no constraint for a
|
||||
// portal the eye is inside, so the neighbour is fully visible. The flood MUST reach the neighbour.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.3f, -0.03f)); // opening 3 cm in front — eye standing in it
|
||||
var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = room };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.CellViews.ContainsKey(0x0002),
|
||||
"eye standing in the doorway must flood the neighbour (degenerate projection was culling it -> void)");
|
||||
Assert.Contains(0x0002u, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion()
|
||||
{
|
||||
// Guard against the fix over-flooding: a portal whose opening the eye is NOT standing in (3 cm
|
||||
// in front but 2 m to the SIDE) also projects degenerate, but the eye is OUTSIDE the opening, so
|
||||
// it must stay culled — otherwise the eye-in-doorway fix would blow up the visible set (#95) by
|
||||
// flooding every degenerate-projecting portal regardless of where the eye actually is.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(2.0f, 0f, 0.3f, 0.3f, -0.03f)); // 2 m to the side — eye NOT in it
|
||||
var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = room };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.False(frame.CellViews.ContainsKey(0x0002),
|
||||
"a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_PortalFullyBehindEye_NotFlooded_RetailEmptyClipRule()
|
||||
{
|
||||
// W=0 port (2026-06-11): a portal ENTIRELY behind the eye clips to empty in retail
|
||||
// polyClipFinish (every vertex w < 0 -> <3 survivors -> reject), so the flood does not
|
||||
// reach its neighbour — the cell is off-screen and drawing nothing through it is correct.
|
||||
//
|
||||
// HISTORY: this test used to assert the OPPOSITE (rescue-era pin from a 2026-06-06 cellar
|
||||
// capture, "0174->0175 must flood at 1.4 m behind the camera"). That pinned the
|
||||
// EyeInsidePortalOpening rescue — the documented compensation for ProjectToClip's old
|
||||
// EyePlaneW=1e-4 divergence — not retail. The rescue is deleted with the polyClipFinish
|
||||
// W=0 port (docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md); the live
|
||||
// cellar behaviors are re-verified by the dat-backed replay harnesses + the visual gate.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // entirely behind the eye
|
||||
var stairs = Cell(0x0002);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = stairs };
|
||||
var vp = ViewProj();
|
||||
|
||||
Assert.True(PortalProjection.ProjectToClip(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3,
|
||||
"a fully-behind portal must clip to empty (polyClipFinish part 1)");
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit()
|
||||
{
|
||||
// Retail PView tracks update_count vs view_count. If B is processed through a LEFT slice, then
|
||||
// a later path reaches B through a RIGHT slice, B must propagate that new RIGHT slice to its
|
||||
// exit portal. Enqueue-once builders flap here: OutsideView stays empty until the camera moves
|
||||
// enough to discover B in the other order.
|
||||
const uint A = 0x0001, B = 0x0002, D = 0x0003;
|
||||
|
||||
var a = Cell(A,
|
||||
new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)D, PolygonId: 1, Flags: 0, OtherPortalId: 0));
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // nearer LEFT path to B
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // farther RIGHT path to D
|
||||
|
||||
var b = Cell(B,
|
||||
new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo(0xFFFF, PolygonId: 1, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)D, PolygonId: 2, Flags: 0, OtherPortalId: 0));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // reciprocal LEFT back to A
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT exit, invisible from LEFT slice
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // reciprocal RIGHT back to D
|
||||
|
||||
var d = Cell(D, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 2));
|
||||
d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // later RIGHT path into B
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [D] = d };
|
||||
|
||||
var frame = Build(a, all);
|
||||
|
||||
Assert.Contains(B, frame.OrderedVisibleCells);
|
||||
Assert.Contains(D, frame.OrderedVisibleCells);
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ViewGrowthAfterDoneCell_RePopsGrownCell()
|
||||
{
|
||||
// Same A->B(near LEFT) + A->D(far RIGHT) + D->B(later) topology as
|
||||
// Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit: B is popped via LEFT, then D grows B
|
||||
// through RIGHT after B is done -> B re-pops. This is retail-faithful late growth (kept by the fix).
|
||||
const uint A = 0x0001, B = 0x0002, D = 0x0003;
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0), new CellPortalInfo((ushort)D, 1, 0, 0));
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f));
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
|
||||
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0),
|
||||
new CellPortalInfo(0xFFFF, 1, 0, 0), new CellPortalInfo((ushort)D, 2, 0, 0));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f));
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f));
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
|
||||
var d = Cell(D, new CellPortalInfo((ushort)B, 0, 0, 2));
|
||||
d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [D] = d };
|
||||
|
||||
var frame = Build(a, all);
|
||||
|
||||
// Membership (the flap-relevant output) is each cell once, regardless of re-pops.
|
||||
Assert.Equal(new[] { B }, frame.OrderedVisibleCells.Where(c => c == B).ToArray());
|
||||
Assert.Contains(D, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0, 0, 0.1f, 1f, -3f));
|
||||
var inner = Cell(0x0002); // no portals at all
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = inner };
|
||||
Assert.True(Build(cam, all).OutsideView.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow()
|
||||
{
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
var frame = Build(cam, all);
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
Assert.True(frame.OutsideView.MaxX - frame.OutsideView.MinX > 0.3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_BackFacingPortal_NotTraversed()
|
||||
{
|
||||
// Portal to 0x0002, but its clip plane puts the camera (origin) on the OUTSIDE.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0, 0, 0.5f, 0.5f, -3f));
|
||||
cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = -1f, InsideSide = 0 });
|
||||
// dot = (0,0,1)·origin + (-1) = -1 < 0; InsideSide==0 requires dot >= -eps → camera OUTSIDE → skip.
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
ground.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
Assert.False(frame.CellViews.ContainsKey(0x0002)); // neighbour never reached
|
||||
Assert.True(frame.OutsideView.IsEmpty); // its window never marked
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_BackFacingPortal_EyeStandingInOpening_StillCulled()
|
||||
{
|
||||
// R-A2b (Option B1, the flap fix): retail PView::InitCell (:432962) culls a back-facing portal by
|
||||
// the side test REGARDLESS of eye proximity — there is NO eye-in-opening bypass. The live pin
|
||||
// (flap-sidechk.log, 2026-06-09) showed the back portal 0173->0171 with camInterior=False but
|
||||
// eyeIn=True (eye within 1.75 m of the shared doorway); the OLD `&& !eyeInsideOpening` side-cull
|
||||
// bypass let it through -> the 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap.
|
||||
// A back-facing portal the eye is STANDING IN must stay culled (the forward-portal clip-empty void
|
||||
// rescue, tested by Build_EyeStandingInInteriorPortal_FloodsNeighbour, is a separate path and stays).
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -1f)); // 1 m in front -> eyeInsideOpening = True
|
||||
// ClipPlane puts the eye on the EXIT side (camInterior = False), like the back portal of a doorway
|
||||
// just crossed: Normal·origin + D = 1 > 0, InsideSide==1 wants dot<=eps -> not interior.
|
||||
cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1f, InsideSide = 1 });
|
||||
var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
ground.PortalPolygons.Add(Quad(0f, 0f, 1f, 1f, -6f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = ground };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.False(frame.CellViews.ContainsKey(0x0002),
|
||||
"a back-facing portal (camInterior=False) must stay culled even when the eye is standing in its " +
|
||||
"opening (eyeInsideOpening=True) — retail's side test has no bypass; the bypass WAS the flap cycle");
|
||||
Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CwWoundExitPortal_OutsideRegionIsCcw()
|
||||
{
|
||||
// Exit portal authored CLOCKWISE — the builder must normalize to CCW so downstream stays valid.
|
||||
var cwQuad = new[]
|
||||
{
|
||||
new Vector3(-1, -1, -6), new Vector3(-1, 1, -6), new Vector3(1, 1, -6), new Vector3(1, -1, -6),
|
||||
};
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(cwQuad);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
var frame = Build(cam, all);
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
var p = frame.OutsideView.Polygons[0].Vertices;
|
||||
float area2 = 0f;
|
||||
for (int i = 0; i < p.Length; i++) { var a = p[i]; var b = p[(i + 1) % p.Length]; area2 += a.X * b.Y - b.X * a.Y; }
|
||||
Assert.True(area2 > 0f, "clipped OutsideView region should be CCW after winding normalization");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CyclicGraph_TerminatesWithBoundedPolys()
|
||||
{
|
||||
// A <-> B cycle; B also has an exit window. Must terminate and not blow up.
|
||||
var a = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f));
|
||||
var b = Cell(0x0002, new CellPortalInfo(0x0001, 0, 0, 0), new CellPortalInfo(0xFFFF, 1, 0, 0));
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // back to A
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // exit window
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = a, [0x0002] = b };
|
||||
|
||||
var frame = Build(a, all); // must return (no infinite loop)
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
Assert.True(frame.OutsideView.Polygons.Count < 256,
|
||||
$"OutsideView poly count {frame.OutsideView.Polygons.Count} — termination/dedup regression guard");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.2a: ordered visible-cell list (closest-first) + grow-watermark
|
||||
// fixpoint termination (replaces MaxReprocessPerCell hard cap).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Straight chain A -> B -> C, camera in A looking down -Z. Each onward portal
|
||||
// is progressively farther in -Z so the camera-to-portal distance is monotonic,
|
||||
// forcing the priority queue to dequeue A, then B, then C in that order.
|
||||
private static (LoadedCell[] cells, Dictionary<uint, LoadedCell> lookup) SyntheticChain()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002, C = 0x0003;
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f)); // portal A->B at z=-2 (nearer)
|
||||
var b = Cell(B, new CellPortalInfo((ushort)C, 0, 0, 0));
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -5f)); // portal B->C at z=-5 (farther)
|
||||
var c = Cell(C, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
c.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -8f)); // exit window
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [C] = c };
|
||||
return (new[] { a, b, c }, all);
|
||||
}
|
||||
|
||||
[Fact] // closest-first ordering
|
||||
public void Build_OrdersVisibleCells_ClosestFirst()
|
||||
{
|
||||
var (cells, lookup) = SyntheticChain();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
Assert.Equal(new uint[] { 0x0001, 0x0002, 0x0003 }, f.OrderedVisibleCells.ToArray());
|
||||
}
|
||||
|
||||
// Hub cell with 4 rooms, each room portal-linked BACK to the hub (a cycle on
|
||||
// every spoke). A naive FIFO with no real fixpoint re-enqueues the hub once per
|
||||
// returning spoke and the rooms once per hub re-process — the watermark must
|
||||
// converge instead, bounding the visible set to {hub + 4 rooms} with no dupes.
|
||||
private static (LoadedCell hub, Dictionary<uint, LoadedCell> lookup) SyntheticCyclicHub()
|
||||
{
|
||||
const uint HUB = 0x0010;
|
||||
uint[] rooms = { 0x0011, 0x0012, 0x0013, 0x0014 };
|
||||
// Hub has one portal to each room; rooms sit at distinct depths so ordering is deterministic.
|
||||
var hub = Cell(HUB,
|
||||
new CellPortalInfo((ushort)rooms[0], 0, 0, 0), new CellPortalInfo((ushort)rooms[1], 1, 0, 0),
|
||||
new CellPortalInfo((ushort)rooms[2], 2, 0, 0), new CellPortalInfo((ushort)rooms[3], 3, 0, 0));
|
||||
for (int i = 0; i < 4; i++)
|
||||
hub.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i)); // -2,-3,-4,-5
|
||||
var all = new Dictionary<uint, LoadedCell> { [HUB] = hub };
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var room = Cell(rooms[i], new CellPortalInfo((ushort)HUB, 0, 0, 0)); // links back to hub → cycle
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i));
|
||||
all[rooms[i]] = room;
|
||||
}
|
||||
return (hub, all);
|
||||
}
|
||||
|
||||
[Fact] // cyclic graph terminates and bounds the visible set
|
||||
public void Build_CyclicHub_TerminatesAndBounds()
|
||||
{
|
||||
var (hub, lookup) = SyntheticCyclicHub();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
hub, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
Assert.True(f.OrderedVisibleCells.Count <= 5,
|
||||
$"hub + 4 rooms expected, got {f.OrderedVisibleCells.Count} — fixpoint failed to converge");
|
||||
Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.2b: reciprocal OtherPortalClip (retail PView::OtherPortalClip
|
||||
// decomp:433524). When a portal leads to a loaded neighbour, the
|
||||
// propagated region must ALSO be clipped against the neighbour's matching
|
||||
// (reciprocal) back-portal polygon — the result is the intersection of the
|
||||
// opening seen from BOTH sides. This can only tighten, never widen.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Camera in A looking down -Z through a WIDE near-side portal (A->B). B's
|
||||
// matching back-portal (B->A) is a NARROW opening at the same plane, so the
|
||||
// reciprocal opening projects to a strictly smaller NDC region. Without the
|
||||
// reciprocal clip, B's CellView equals the wide near-side projection; with
|
||||
// it, B's CellView is bounded by the narrow reciprocal opening.
|
||||
private static (LoadedCell camCell, LoadedCell neighbour, Dictionary<uint, LoadedCell> lookup)
|
||||
SyntheticReciprocalPair()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
// A's portal into B: wide opening (half-width 0.9) at z = -3. Its
|
||||
// reciprocal back-portal lives at index 0 in B (OtherPortalId = 0).
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
|
||||
// B's reciprocal portal back to A: NARROW opening (half-width 0.3),
|
||||
// same height and plane, so it projects fully inside the near-side rect
|
||||
// but covers only ~1/3 of its width.
|
||||
var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0));
|
||||
b.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.9f, -3f));
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
return (a, b, all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AppliesReciprocalOtherPortalClip()
|
||||
{
|
||||
var (camCell, neighbour, lookup) = SyntheticReciprocalPair();
|
||||
var vp = ViewProj();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated");
|
||||
|
||||
// The reciprocal opening's area in NDC: project B's narrow back-portal
|
||||
// polygon and take its (CCW-magnitude) shoelace area. This is the
|
||||
// tightest the neighbour region can be — the propagated region must not
|
||||
// exceed it once both-sides clipping is applied.
|
||||
float reciprocalArea = ProjectedPolygonArea(neighbour.PortalPolygons[0], vp);
|
||||
|
||||
float area = CellViewArea(f.CellViews[0x0002]);
|
||||
const float eps = 1e-4f;
|
||||
Assert.True(area <= reciprocalArea + eps,
|
||||
$"neighbour CellView area {area} must be clipped to the narrower reciprocal opening " +
|
||||
$"{reciprocalArea} (without OtherPortalClip it equals the WIDE near-side projection)");
|
||||
|
||||
// Falsifiability guard: the near-side projection is genuinely WIDER than
|
||||
// the reciprocal, so a no-op clip would have failed the assertion above.
|
||||
float nearSideArea = ProjectedPolygonArea(camCell.PortalPolygons[0], vp);
|
||||
Assert.True(nearSideArea > reciprocalArea * 1.5f,
|
||||
$"fixture sanity: near-side area {nearSideArea} must dominate reciprocal {reciprocalArea}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReciprocalClip_DegradesGracefully_WhenNoBackPortal()
|
||||
{
|
||||
// Same wide A->B opening, but B has NO portal pointing back to A (data gap).
|
||||
// The reciprocal clip must no-op, so B's CellView equals the WIDE near-side
|
||||
// projection — proving degradation never under-includes (and never throws).
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0));
|
||||
a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f));
|
||||
var b = Cell(B); // no portals at all → no back-portal to match
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
var vp = ViewProj();
|
||||
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.True(f.CellViews.ContainsKey(B), "neighbour must still be reached when no back-portal exists");
|
||||
float nearSideArea = ProjectedPolygonArea(a.PortalPolygons[0], vp);
|
||||
float area = CellViewArea(f.CellViews[B]);
|
||||
Assert.True(System.MathF.Abs(area - nearSideArea) < 1e-3f,
|
||||
$"with no reciprocal portal the region must equal the near-side projection " +
|
||||
$"(got {area}, near-side {nearSideArea}) — degrade must not tighten or expand");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.2b CRITICAL — multiple portals to the SAME neighbour must each
|
||||
// resolve their OWN reciprocal back-portal (retail arg2->other_portal_id,
|
||||
// decomp:433557), NOT the first OtherCellId match. This is the real
|
||||
// Holtburg-cellar shape: cell 0x148 has two portals to 0x149 (poly 40, 41)
|
||||
// and 0x149 has two reciprocals back to 0x148. A scan-by-first-match clips
|
||||
// BOTH near-side openings against the FIRST reciprocal — if the apertures
|
||||
// are disjoint, the second opening's intersection underflows to empty and
|
||||
// its geometry is HIDDEN (under-inclusion). Direct-index resolution clips
|
||||
// each opening against the matching reciprocal, so both survive.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Camera in A looking down -Z. A has TWO portals to the SAME neighbour B,
|
||||
// with DISJOINT openings: a LEFT aperture (x in [-0.9,-0.3]) and a RIGHT
|
||||
// aperture (x in [0.3,0.9]). B has two reciprocals back to A, one matching
|
||||
// each side. A.Portals[0].OtherPortalId = 0 (B's LEFT reciprocal),
|
||||
// A.Portals[1].OtherPortalId = 1 (B's RIGHT reciprocal).
|
||||
//
|
||||
// Scan-by-first-match resolves BOTH A-portals to B.Portals[0] (LEFT),
|
||||
// so the RIGHT near-side region (x>0) intersects an x<0 reciprocal → empty
|
||||
// → B's CellView never reaches the RIGHT aperture. Direct-index keeps it.
|
||||
private static (LoadedCell camCell, LoadedCell neighbour, Dictionary<uint, LoadedCell> lookup)
|
||||
SyntheticMultiBackPortalPair()
|
||||
{
|
||||
const uint A = 0x0001, B = 0x0002;
|
||||
// A: portal[0] → B through the LEFT opening, portal[1] → B through the RIGHT opening.
|
||||
// The OtherPortalId back-links pick the matching reciprocal in B (0 = LEFT, 1 = RIGHT).
|
||||
var a = Cell(A,
|
||||
new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0), // LEFT → B recip[0]
|
||||
new CellPortalInfo((ushort)B, PolygonId: 1, Flags: 0, OtherPortalId: 1)); // RIGHT → B recip[1]
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // LEFT near-side aperture
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT near-side aperture
|
||||
|
||||
// B: two reciprocals back to A. Index 0 covers the LEFT opening, index 1
|
||||
// the RIGHT opening — disjoint, same plane (z=-3) as the near side so each
|
||||
// reciprocal exactly overlaps its matching A aperture.
|
||||
var b = Cell(B,
|
||||
new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)A, PolygonId: 1, Flags: 0, OtherPortalId: 1));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // reciprocal[0] = LEFT
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // reciprocal[1] = RIGHT
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b };
|
||||
return (a, b, all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal()
|
||||
{
|
||||
var (camCell, neighbour, lookup) = SyntheticMultiBackPortalPair();
|
||||
var vp = ViewProj();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated");
|
||||
|
||||
// The RIGHT reciprocal opening (B.PortalPolygons[1]) projects to a region
|
||||
// with strictly positive NDC X. Geometry visible through the SECOND opening
|
||||
// survives ONLY if A.Portals[1] was clipped against B's RIGHT reciprocal
|
||||
// (index 1) rather than the LEFT one (index 0).
|
||||
var rightNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[1], Matrix4x4.Identity, vp);
|
||||
float rightMinX = float.MaxValue, rightMaxX = float.MinValue;
|
||||
foreach (var v in rightNdc) { if (v.X < rightMinX) rightMinX = v.X; if (v.X > rightMaxX) rightMaxX = v.X; }
|
||||
Assert.True(rightMinX > 0f, $"fixture sanity: RIGHT reciprocal must project to positive NDC X (got minX {rightMinX})");
|
||||
|
||||
// The neighbour CellView must extend into the RIGHT aperture. With the
|
||||
// scan-by-first-match bug both near-side openings clip against the LEFT
|
||||
// reciprocal (x<0), so the CellView's MaxX stays well left of rightMinX
|
||||
// and this assertion FAILS (the RIGHT opening's geometry is hidden).
|
||||
var bView = f.CellViews[0x0002];
|
||||
Assert.True(bView.MaxX >= rightMinX - 1e-4f,
|
||||
$"neighbour CellView MaxX {bView.MaxX} must reach the RIGHT reciprocal opening " +
|
||||
$"[{rightMinX}, {rightMaxX}] — under scan-by-first-match the second opening is clipped " +
|
||||
$"against the LEFT reciprocal and HIDDEN (under-inclusion bug #102 M-4).");
|
||||
|
||||
// And the LEFT aperture must still be present (the first opening was never
|
||||
// in question) — guards against a fix that accidentally drops the LEFT side.
|
||||
var leftNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[0], Matrix4x4.Identity, vp);
|
||||
float leftMinX = float.MaxValue;
|
||||
foreach (var v in leftNdc) if (v.X < leftMinX) leftMinX = v.X;
|
||||
Assert.True(bView.MinX <= leftMinX + 1e-4f,
|
||||
$"neighbour CellView MinX {bView.MinX} must still cover the LEFT reciprocal opening (minX {leftMinX})");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase U.4c: an earlier synthetic flap test (Build_NearBoundaryIntermediatePortal)
|
||||
// lived here. It modeled the doorway flap as a BUILDER side-test cull dropping the
|
||||
// exit cell. The live ACDREAM_PROBE_FLAP capture (2026-05-31) DISPROVED that model:
|
||||
// the real cause is the visibility ROOT being driven by the 3rd-person camera EYE —
|
||||
// the eye drifts out of the player's cell, FindCameraCell returns a STALE cell for
|
||||
// its grace frames, and the doorway is then culled as "behind" the eye. The fix is
|
||||
// at the GameWindow integration level (root visibility at the PLAYER's cell:
|
||||
// visRootPos), NOT in the builder — the builder's side test is correct and unchanged,
|
||||
// so the old test asserted a non-bug and was removed rather than left red. The fix is
|
||||
// validated by the visual gate + the [flap] probe (RenderingDiagnostics.ProbeFlapEnabled);
|
||||
// see docs/research/2026-05-31-u4c-flap-characterization.md.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase W Stage 4/5: BFS exit-portal + root-cell ordering guarantees
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Build_ExitPortalVisible_OutsideViewNonEmpty()
|
||||
{
|
||||
// A cell with a single exit portal (OtherCellId == 0xFFFF) whose polygon
|
||||
// projects in front of the camera. The BFS must union the clipped region
|
||||
// into OutsideView, making it non-empty.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); // in front, in frustum
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.OutsideView.Polygons.Count > 0,
|
||||
"An exit portal (OtherCellId=0xFFFF) in front of the camera must populate OutsideView");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
// A cell whose only portal leads to another interior cell (OtherCellId != 0xFFFF).
|
||||
// With no exit portal in any reachable cell the OutsideView must remain empty.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f));
|
||||
var inner = Cell(0x0002); // no portals — no exit
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = inner };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.OutsideView.Polygons.Count == 0,
|
||||
"No exit portal in any reachable cell must leave OutsideView empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_SeedsInteriorCellThroughOutsidePortal()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room },
|
||||
Vector3.Zero,
|
||||
id => id == room.CellId ? room : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Contains(room.CellId, frame.OrderedVisibleCells);
|
||||
Assert.True(frame.CellViews.TryGetValue(room.CellId, out var view));
|
||||
Assert.False(view!.IsEmpty);
|
||||
Assert.True(view.MaxX - view.MinX < 1.0f,
|
||||
"exterior seed should be clipped to the door opening, not full-screen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_ReusedFrame_ClearsPriorOutputAndProducesSameResult()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
var reuse = new PortalVisibilityFrame();
|
||||
LoadedCell? Lookup(uint id) => id == room.CellId ? room : null;
|
||||
|
||||
var first = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, first);
|
||||
Assert.Equal(new[] { room.CellId }, first.OrderedVisibleCells);
|
||||
|
||||
var empty = PortalVisibilityBuilder.BuildFromExterior(
|
||||
Array.Empty<LoadedCell>(), Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, empty);
|
||||
Assert.Empty(empty.OrderedVisibleCells);
|
||||
Assert.Empty(empty.CellViews);
|
||||
Assert.True(empty.OutsideView.IsEmpty);
|
||||
|
||||
var second = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
|
||||
Assert.Same(reuse, second);
|
||||
Assert.Equal(new[] { room.CellId }, second.OrderedVisibleCells);
|
||||
Assert.True(second.CellViews.TryGetValue(room.CellId, out CellView? view));
|
||||
Assert.False(view!.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = -1f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room },
|
||||
Vector3.Zero,
|
||||
id => id == room.CellId ? room : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Empty(frame.OrderedVisibleCells);
|
||||
Assert.Empty(frame.CellViews);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_TraversesDeeperInteriorPortals()
|
||||
{
|
||||
var entry = Cell(0x0001,
|
||||
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
||||
new CellPortalInfo(0x0002, 1, 0, 0));
|
||||
entry.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -3f));
|
||||
entry.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, -5f));
|
||||
entry.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 2f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var backRoom = Cell(0x0002);
|
||||
var all = new Dictionary<uint, LoadedCell>
|
||||
{
|
||||
[entry.CellId] = entry,
|
||||
[backRoom.CellId] = backRoom,
|
||||
};
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { entry },
|
||||
Vector3.Zero,
|
||||
id => all.TryGetValue(id, out var c) ? c : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Equal(new uint[] { 0x0001, 0x0002 }, frame.OrderedVisibleCells.ToArray());
|
||||
Assert.True(frame.CellViews.ContainsKey(0x0002));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_MaxSeedDistanceSkipsDistantExitPortal()
|
||||
{
|
||||
var nearby = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
nearby.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
nearby.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var distant = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
distant.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -200f));
|
||||
distant.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 199f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { nearby, distant },
|
||||
Vector3.Zero,
|
||||
id => id == nearby.CellId ? nearby : id == distant.CellId ? distant : null,
|
||||
ViewProj(),
|
||||
maxSeedDistance: 48f);
|
||||
|
||||
Assert.Contains(nearby.CellId, frame.OrderedVisibleCells);
|
||||
Assert.DoesNotContain(distant.CellId, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RootCellAlwaysFirstInOrderedVisibleCells()
|
||||
{
|
||||
// The camera cell seeds the BFS at distance 0 so it must always pop
|
||||
// first → OrderedVisibleCells[0] == cameraCell.CellId.
|
||||
// Use the three-cell chain: A→B→C with exit window on C.
|
||||
var (cells, lookup) = SyntheticChain();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
Assert.NotEmpty(f.OrderedVisibleCells);
|
||||
Assert.Equal(cells[0].CellId, f.OrderedVisibleCells[0]);
|
||||
}
|
||||
|
||||
// Note: Build_CyclicPortalGraph_Terminates is already covered by
|
||||
// Builder_CyclicGraph_TerminatesWithBoundedPolys and Build_CyclicHub_TerminatesAndBounds.
|
||||
// The dedup invariant (each cell appears at most once) is validated by
|
||||
// Build_CyclicHub_TerminatesAndBounds via OrderedVisibleCells.Distinct().Count().
|
||||
|
||||
// Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC.
|
||||
private static float CellViewArea(CellView view)
|
||||
{
|
||||
float total = 0f;
|
||||
foreach (var poly in view.Polygons) total += ShoelaceAbs(poly.Vertices);
|
||||
return total;
|
||||
}
|
||||
|
||||
// Project a cell-local polygon (identity world transform in these fixtures)
|
||||
// to NDC via the same path the builder uses, then take its area magnitude.
|
||||
private static float ProjectedPolygonArea(Vector3[] localPoly, Matrix4x4 vp)
|
||||
{
|
||||
var ndc = PortalProjection.ProjectToNdc(localPoly, Matrix4x4.Identity, vp);
|
||||
return ShoelaceAbs(ndc);
|
||||
}
|
||||
|
||||
private static float ShoelaceAbs(Vector2[] poly)
|
||||
{
|
||||
if (poly == null || poly.Length < 3) return 0f;
|
||||
float area2 = 0f;
|
||||
for (int i = 0; i < poly.Length; i++)
|
||||
{
|
||||
var p = poly[i];
|
||||
var q = poly[(i + 1) % poly.Length];
|
||||
area2 += p.X * q.Y - q.X * p.Y;
|
||||
}
|
||||
return System.MathF.Abs(area2) * 0.5f;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PortalFrameTestHelper
|
||||
{
|
||||
public static float ProjectedWidth(Vector3[] worldSeg, Matrix4x4 vp)
|
||||
{
|
||||
var a = Vector4.Transform(new Vector4(worldSeg[0], 1f), vp);
|
||||
var b = Vector4.Transform(new Vector4(worldSeg[1], 1f), vp);
|
||||
return System.MathF.Abs(a.X / a.W - b.X / b.W);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// R-A2 conformance: the per-building flood (<see cref="PortalVisibilityBuilder.ConstructViewBuilding"/>)
|
||||
/// is the robustness mechanism for the doorway flap. These are SOUNDNESS gates against the measured
|
||||
/// retail values (handoff 2026-06-08 OPTION-A §3.4): a per-building flood touches ≈2 cells and its
|
||||
/// membership is stable under the eye's ~36 µm rest jitter. They prove the per-building path is built
|
||||
/// correctly and does not regress; the live doorway is the visual acceptance test for "the flap is
|
||||
/// gone" (a synthetic fixture cannot reproduce the cottage's exact knife-edge geometry).
|
||||
/// </summary>
|
||||
public class PortalVisibilityRobustnessTests
|
||||
{
|
||||
private static Matrix4x4 ViewProj()
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
|
||||
{
|
||||
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
|
||||
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
|
||||
};
|
||||
|
||||
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
|
||||
{
|
||||
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = new List<CellPortalInfo>(portals),
|
||||
};
|
||||
|
||||
// A realistic 2-cell building viewed from OUTSIDE: a vestibule (0x0170) with an entrance portal to
|
||||
// the outdoors plus an interior portal to a back room (0x0171). The entrance clip plane puts the
|
||||
// origin eye on the EXTERIOR side (D=3, InsideSide=1 → dot=3 > ε), so the exterior seed fires —
|
||||
// exactly the BuildFromExterior seeding contract (mirrors the existing BuildFromExterior_* fixtures).
|
||||
private static (LoadedCell[] cells, Dictionary<uint, LoadedCell> lookup) TwoCellBuilding()
|
||||
{
|
||||
const uint VEST = 0x0170, ROOM = 0x0171;
|
||||
var vest = Cell(VEST,
|
||||
new CellPortalInfo(0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0), // entrance to outdoors
|
||||
new CellPortalInfo((ushort)ROOM, PolygonId: 1, Flags: 0, OtherPortalId: 0)); // interior to room
|
||||
vest.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // entrance opening, in front of eye
|
||||
vest.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); // vestibule -> room, deeper
|
||||
vest.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 3f, InsideSide = 1 });
|
||||
|
||||
var room = Cell(ROOM, new CellPortalInfo((ushort)VEST, PolygonId: 0, Flags: 0, OtherPortalId: 1));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); // reciprocal back to vestibule
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [VEST] = vest, [ROOM] = room };
|
||||
return (new[] { vest, room }, all);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructViewBuilding_FloodsTheBuildingFromItsEntrance()
|
||||
{
|
||||
var (cells, lookup) = TwoCellBuilding();
|
||||
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
Assert.Contains(0x0170u, frame.OrderedVisibleCells); // entrance cell seeded
|
||||
Assert.Contains(0x0171u, frame.OrderedVisibleCells); // back room reached through the interior portal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructViewBuilding_TouchesAboutTwoCells()
|
||||
{
|
||||
// Conformance to §3.4: each retail per-building flood has cell_draw_num ≈ 2.
|
||||
var (cells, lookup) = TwoCellBuilding();
|
||||
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
Assert.InRange(frame.OrderedVisibleCells.Count, 1, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructViewBuilding_MembershipStableUnderMicrometreEyeJitter()
|
||||
{
|
||||
// Conformance to §3.4: retail's per-building membership is stable while the eye jitters ~36 µm
|
||||
// at rest (measured X≈15 µm, Y≈36 µm, Z≈8 µm). The entrance-bounded seed must return the SAME
|
||||
// OrderedVisibleCells for the eye and the eye + that per-axis jitter — no flap.
|
||||
var (cells, lookup) = TwoCellBuilding();
|
||||
var vp = ViewProj();
|
||||
System.Func<uint, LoadedCell?> lk = id => lookup.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
var a = PortalVisibilityBuilder.ConstructViewBuilding(cells, Vector3.Zero, lk, vp);
|
||||
var b = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
cells, new Vector3(15e-6f, 36e-6f, 8e-6f), lk, vp);
|
||||
|
||||
Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells);
|
||||
}
|
||||
}
|
||||
|
|
@ -564,12 +564,8 @@ public sealed class RetailPViewPassExecutorTests
|
|||
var frames = new GpuDeviceFrameLifetime(device);
|
||||
var scope = new VulkanWorldPassScope(sampleCount: 1);
|
||||
using var portalDepthMask = new PortalDepthMaskRenderer(device, frames, scope);
|
||||
var diagnostics = new WorldRenderDiagnostics(new NeverCalledGlStateReader(), new NeverCalledDiagnosticLog());
|
||||
|
||||
// DrawPortalDepthWrite only reads _portalDepthMask, frame.Cells,
|
||||
// frame.RootCell.IsOutdoorNode, frame.ViewProjection, and
|
||||
// _diagnostics (short-circuited off by RenderingDiagnostics.
|
||||
// ProbeSeamDrawEnabled's default-off value) — the same
|
||||
// frame.RootCell.IsOutdoorNode, and frame.ViewProjection — the same
|
||||
// constructor-bypass pattern WalkOutsideViewReassemblyTests already
|
||||
// uses for exercising a real leaf without a full GL/DAT renderer
|
||||
// graph.
|
||||
|
|
@ -578,10 +574,6 @@ public sealed class RetailPViewPassExecutorTests
|
|||
typeof(RetailPViewPassExecutor)
|
||||
.GetField("_portalDepthMask", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(executor, portalDepthMask);
|
||||
typeof(RetailPViewPassExecutor)
|
||||
.GetField("_diagnostics", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(executor, diagnostics);
|
||||
|
||||
var root = new LoadedCell { CellId = 0xF4180003u, IsOutdoorNode = false };
|
||||
RetailPViewFrameInput frame = new RetailPViewFrameInput().Reset(
|
||||
rootCell: root,
|
||||
|
|
@ -625,21 +617,6 @@ public sealed class RetailPViewPassExecutorTests
|
|||
public LoadedCell? Find(uint cellId) => cellId == cell.CellId ? cell : null;
|
||||
}
|
||||
|
||||
private sealed class NeverCalledGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() =>
|
||||
throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state.");
|
||||
|
||||
public RenderGlScissorSnapshot CaptureScissor() =>
|
||||
throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state.");
|
||||
}
|
||||
|
||||
private sealed class NeverCalledDiagnosticLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public void WriteLine(string message) =>
|
||||
throw new InvalidOperationException("EmitSeamMask must short-circuit before logging (ProbeSeamDrawEnabled defaults off).");
|
||||
}
|
||||
|
||||
private static int RequiredCallIndex(
|
||||
IReadOnlyList<CompiledCall> calls,
|
||||
Type declaringType,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(false, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -25,7 +25,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new ThrowOnceLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -47,7 +47,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new ThrowOnFirstFrameLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -70,7 +70,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
|
|
@ -99,7 +99,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.AccumulateWalkBatch(1_000);
|
||||
|
|
@ -121,7 +121,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
// not depend on AccumulateWalkBatch ever having been called.
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.CompleteWalkFrame(10_000);
|
||||
|
|
@ -135,7 +135,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var world = new WorldRenderDiagnostics(log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.AccumulateWalkBatch(5_000);
|
||||
|
|
@ -171,12 +171,6 @@ public sealed class TerrainDrawDiagnosticsControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class NullGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
||||
private sealed class RecordingLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
|
|
|||
|
|
@ -1,429 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #119 residual — the tower-ascent harness (the #118 exit-walk pattern,
|
||||
/// vertical). User report (post-#120 build): running UP the AAB3 tower
|
||||
/// (cell 0xAAB30107, building[1] model 0x01001117) the TOP stairs disappear
|
||||
/// visually but stay walkable, and at the top the roof/edges FLAP in and
|
||||
/// out. The tower's upper cells are thin landing/roof slabs with EXIT
|
||||
/// portals (0x0108 z≈121–123, 0x0109 z≈112–114.5, 0x010A z≈126.8–127.2 —
|
||||
/// the roof lip), so the ascent walks the eye across exit-portal planes —
|
||||
/// the same decision-stack territory as #118/#120.
|
||||
///
|
||||
/// Per step of a helix matching the spiral staircase, this drives the
|
||||
/// production stack headlessly: viewer-cell resolution (smallest containing
|
||||
/// AABB; outdoor when none) → PortalVisibilityBuilder.Build (+ the
|
||||
/// per-building exterior floods on outdoor steps, mirroring
|
||||
/// RetailPViewRenderer.MergeNearbyBuildingFloods) → ClipFrameAssembler →
|
||||
/// ViewconeCuller → the DrawCellObjectLists predicate for the staircase
|
||||
/// static (Setup 0x020003F2, ParentCellId 0x0107). The failing steps pin
|
||||
/// the vanish; root/visibility instability across adjacent steps pins the
|
||||
/// flap.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public class TowerAscentReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public TowerAscentReplayTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private const uint Landblock = 0xAAB30000u;
|
||||
private const uint MainCell = Landblock | 0x0107u;
|
||||
|
||||
// Tower geometry (Issue119TowerDumpTests dat facts): origin (108,60,112);
|
||||
// the staircase Setup spans local x,y ∈ [-4,4], z ∈ [0,15.5]. Production
|
||||
// EntitySphere = AABB center + half-diagonal.
|
||||
private static readonly Vector3 TowerCenter = new(108f, 60f, 112f);
|
||||
private static readonly Vector3 StairSphereCenter = new(108f, 60f, 119.75f);
|
||||
private const float StairSphereRadius = 9.45f;
|
||||
|
||||
private sealed record AscentStep(
|
||||
int Index,
|
||||
Vector3 Eye,
|
||||
uint RootCellId, // 0 = outdoor
|
||||
bool FloodHasMainCell,
|
||||
bool StairsConeVisible,
|
||||
int OutsidePolys,
|
||||
int FloodCells);
|
||||
|
||||
private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
private static uint? ResolveInteriorCellByAabb(
|
||||
Dictionary<uint, LoadedCell> cells, Vector3 worldPoint, float margin = 0.05f)
|
||||
{
|
||||
uint? best = null;
|
||||
float bestVolume = float.MaxValue;
|
||||
foreach (var (id, cell) in cells)
|
||||
{
|
||||
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
|
||||
var min = cell.LocalBoundsMin - new Vector3(margin);
|
||||
var max = cell.LocalBoundsMax + new Vector3(margin);
|
||||
if (local.X < min.X || local.Y < min.Y || local.Z < min.Z
|
||||
|| local.X > max.X || local.Y > max.Y || local.Z > max.Z)
|
||||
continue;
|
||||
var ext = cell.LocalBoundsMax - cell.LocalBoundsMin;
|
||||
float volume = MathF.Max(ext.X, 1e-3f) * MathF.Max(ext.Y, 1e-3f) * MathF.Max(ext.Z, 1e-3f);
|
||||
if (volume < bestVolume) { bestVolume = volume; best = id; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Mirror of RetailPViewRenderer.MergeBuildingFrame (union semantics).
|
||||
private static void MergeFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
|
||||
{
|
||||
foreach (uint cellId in src.OrderedVisibleCells)
|
||||
{
|
||||
if (!src.CellViews.TryGetValue(cellId, out var srcView)) continue;
|
||||
if (target.CellViews.TryGetValue(cellId, out var existing))
|
||||
{
|
||||
foreach (var p in srcView.Polygons) existing.Add(p);
|
||||
continue;
|
||||
}
|
||||
target.CellViews[cellId] = srcView;
|
||||
target.OrderedVisibleCells.Add(cellId);
|
||||
}
|
||||
}
|
||||
|
||||
private List<AscentStep> RunAscent()
|
||||
{
|
||||
var datDir = RequireDatDir();
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
Assert.True(cells.ContainsKey(MainCell), "tower main cell not loaded");
|
||||
|
||||
// The tower building's cell list (Issue119TowerDumpTests: building[1]
|
||||
// stabs = 0x0107..0x010A) — the per-building flood candidates on
|
||||
// outdoor-root steps, mirroring GameWindow's gather.
|
||||
var towerCells = new List<LoadedCell>();
|
||||
for (uint low = 0x0107u; low <= 0x010Au; low++)
|
||||
if (cells.TryGetValue(Landblock | low, out var c))
|
||||
towerCells.Add(c);
|
||||
|
||||
// Eye helix matching the spiral: two turns, radius 2.5 m, z from head
|
||||
// height at the base to above the roof lip (cell 0x010A tops at 127.2).
|
||||
const int Steps = 220;
|
||||
const float ZStart = 113.6f, ZEnd = 128.6f;
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
var steps = new List<AscentStep>(Steps + 1);
|
||||
|
||||
for (int i = 0; i <= Steps; i++)
|
||||
{
|
||||
float t = i / (float)Steps;
|
||||
float theta = t * 4f * MathF.PI;
|
||||
float z = ZStart + t * (ZEnd - ZStart);
|
||||
var eye = new Vector3(
|
||||
TowerCenter.X + 2.5f * MathF.Cos(theta),
|
||||
TowerCenter.Y + 2.5f * MathF.Sin(theta),
|
||||
z);
|
||||
// look AT the staircase (the user's framing while climbing: the
|
||||
// camera tracks the player on the stairs). An unambiguous pin:
|
||||
// if the staircase cone-culls while looked at directly, the
|
||||
// visibility stack is wrong — no gaze excuse.
|
||||
var gaze = StairSphereCenter - eye;
|
||||
if (gaze.LengthSquared() < 1e-4f) gaze = new Vector3(0f, 0f, -1f);
|
||||
var viewProj = ViewProjFor(eye, eye + Vector3.Normalize(gaze) * 3f);
|
||||
|
||||
uint rootCellId = 0u;
|
||||
PortalVisibilityFrame pv;
|
||||
var contained = ResolveInteriorCellByAabb(cells, eye);
|
||||
if (contained.HasValue)
|
||||
{
|
||||
rootCellId = contained.Value;
|
||||
pv = PortalVisibilityBuilder.Build(cells[rootCellId], eye, lookup, viewProj);
|
||||
}
|
||||
else
|
||||
{
|
||||
// outdoor root: full-screen outside view + the per-building
|
||||
// exterior floods (RetailPViewRenderer.MergeNearbyBuildingFloods)
|
||||
var outdoor = OutdoorCellNode.Build(Landblock | 0x0001u);
|
||||
pv = PortalVisibilityBuilder.Build(outdoor, eye, lookup, viewProj);
|
||||
var bf = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
towerCells, eye, lookup, viewProj);
|
||||
MergeFrame(pv, bf);
|
||||
}
|
||||
|
||||
var asm = ClipFrameAssembler.Assemble(clipFrame, pv);
|
||||
var cone = ViewconeCuller.Build(asm, viewProj);
|
||||
|
||||
bool floodHasMain = pv.CellViews.ContainsKey(MainCell);
|
||||
// DrawCellObjectLists predicate for a 0x0107 static: sphere vs the
|
||||
// cell's views (RetailPViewRenderer.cs DrawCellObjectLists / T3).
|
||||
bool stairsVisible = cone.SphereVisibleInCell(MainCell, StairSphereCenter, StairSphereRadius);
|
||||
|
||||
steps.Add(new AscentStep(
|
||||
i, eye, rootCellId, floodHasMain, stairsVisible,
|
||||
pv.OutsideView.Polygons.Count, pv.OrderedVisibleCells.Count));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private void DumpSteps(IEnumerable<AscentStep> steps, Func<AscentStep, bool>? filter = null)
|
||||
{
|
||||
foreach (var st in steps)
|
||||
{
|
||||
if (filter is not null && !filter(st)) continue;
|
||||
string root = st.RootCellId == 0 ? "OUTDOOR " : FormattableString.Invariant($"0x{st.RootCellId:X8}");
|
||||
string stairs = st.StairsConeVisible ? "VIS " : "CULL";
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"step={st.Index,3} eye=({st.Eye.X:F2},{st.Eye.Y:F2},{st.Eye.Z:F2}) root={root} floodMain={st.FloodHasMainCell} stairs={stairs} outPolys={st.OutsidePolys} flood={st.FloodCells}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The vanish pin: the staircase static must stay cone-visible on every
|
||||
/// ascent step (the gaze looks STRAIGHT AT it — no gaze excuse).
|
||||
///
|
||||
/// PINNED RED 2026-06-11 (skip carries the defect; un-skip with the fix):
|
||||
/// steps 195–201 (eye z 126.9–127.3, the roof-lip band between the main
|
||||
/// cell's ceiling at 126.8 and the roof aperture plane at ~127.2) resolve
|
||||
/// OUTDOOR and the per-building exterior flood admits NOTHING (flood=1 =
|
||||
/// the outdoor node alone) — the eye is above every side aperture's
|
||||
/// useful view and ON/INSIDE the roof aperture's plane, so
|
||||
/// BuildFromExterior's seed side-test/in-plane reject refuses every exit
|
||||
/// portal. The tower interior never floods → the staircase (a 0x0107
|
||||
/// static) has no views → culled while walkable, and the roof-lip cell
|
||||
/// geometry flaps as the live eye bobs across the band's edges. Fix
|
||||
/// needs the retail oracle: does retail keep the viewer cell INTERIOR
|
||||
/// through this band (curr_cell keep-curr above open-top cells), or can
|
||||
/// ConstructView(CBldPortal) seed with an in-plane eye?
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void TowerAscent_StaircaseStaysConeVisible_EveryStep()
|
||||
{
|
||||
var steps = RunAscent();
|
||||
var failures = steps.FindAll(s => !s.StairsConeVisible);
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"--- {failures.Count} stairs-CULLED steps ---");
|
||||
DumpSteps(failures);
|
||||
}
|
||||
Assert.True(failures.Count == 0,
|
||||
$"{failures.Count}/{steps.Count} ascent steps cone-cull the staircase (first at step {(failures.Count > 0 ? failures[0].Index : -1)}) — see output");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The flap pin: along a smooth monotone ascent, the resolved root may
|
||||
/// transition between cells/outdoor but must not PING-PONG (A→B→A within
|
||||
/// three consecutive steps = a knife-edge decision the live camera jitter
|
||||
/// turns into per-frame flapping).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TowerAscent_RootDoesNotPingPong()
|
||||
{
|
||||
var steps = RunAscent();
|
||||
var flips = new List<int>();
|
||||
for (int i = 2; i < steps.Count; i++)
|
||||
{
|
||||
uint a = steps[i - 2].RootCellId, b = steps[i - 1].RootCellId, c = steps[i].RootCellId;
|
||||
if (a != b && b != c && a == c)
|
||||
flips.Add(i - 1);
|
||||
}
|
||||
if (flips.Count > 0)
|
||||
{
|
||||
_out.WriteLine($"--- {flips.Count} root ping-pong steps ---");
|
||||
DumpSteps(steps, s => flips.Contains(s.Index) || flips.Contains(s.Index - 1) || flips.Contains(s.Index + 1));
|
||||
}
|
||||
Assert.True(flips.Count == 0,
|
||||
$"{flips.Count} root ping-pongs along a monotone ascent — knife-edge root decisions (see output)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE captured top-of-stairs vanish (flap-diff-capture.log, live user
|
||||
/// pan): root=0xAAB3010A (the roof-lip cell), eye 3 mm above its floor
|
||||
/// portal plane, gazing DOWN at the staircase — and the flood admitted
|
||||
/// ONLY {010A}: 0x0107 (the whole tower interior, staircase included)
|
||||
/// dropped while being looked straight at. Exact production inputs:
|
||||
/// eye=(301.448,-128.448,126.803) fwd=(-0.9361,-0.2459,-0.2514)
|
||||
/// eye=(301.371,-128.401,126.807) fwd=(-0.8938,-0.3596,-0.2678)
|
||||
/// The 010A→0107 portal is HORIZONTAL at z≈126.8 — the eye rides within
|
||||
/// millimetres of its plane at the stair top, knife-edge projection
|
||||
/// territory (the #120 family's geometry, admission-side).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(301.448f, -128.448f, 126.803f, -0.9361f, -0.2459f, -0.2514f)]
|
||||
[InlineData(301.371f, -128.401f, 126.807f, -0.8938f, -0.3596f, -0.2678f)]
|
||||
public void CapturedTopOfStairs_MainCellStaysInFlood(
|
||||
float ex, float ey, float ez, float fx, float fy, float fz)
|
||||
{
|
||||
var datDir = RequireDatDir();
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
Func<uint, LoadedCell?> lookup = id => cells.TryGetValue(id, out var c) ? c : null;
|
||||
|
||||
// The capture is A9B4-anchored world; the loaded cells are AAB3-local
|
||||
// (LoadCell without offset). AAB3 = A9B4 + (1,-1) blocks → local =
|
||||
// world - (192,-192,0).
|
||||
var eye = new Vector3(ex - 192f, ey + 192f, ez);
|
||||
var fwd = Vector3.Normalize(new Vector3(fx, fy, fz));
|
||||
|
||||
// ROOT CAUSE (found via this capture): production fed BuildLoadedCell
|
||||
// the +0.02 m RENDER-LIFTED transform ("keep the small render lift out
|
||||
// of physics", GameWindow:5567) — the visibility graph's HORIZONTAL
|
||||
// portal planes all sat 2 cm high, so an eye standing on the deck
|
||||
// (3 mm above the TRUE plane) was 17 mm BELOW the lifted plane —
|
||||
// outside the side test's ±10 mm in-plane window → 010A→0107
|
||||
// side-culled → the staircase vanished while walkable. FIXED: the
|
||||
// visibility LoadedCell now gets the PHYSICS (unlifted) transform.
|
||||
//
|
||||
// Arm 1 (unlifted = post-fix production): the main cell must be
|
||||
// admitted. Arm 2 (lifted = the old bug, kept as the mechanism
|
||||
// canary): the main cell is dropped — if this arm ever starts
|
||||
// PASSING, the in-plane window has changed enough to absorb a 2 cm
|
||||
// lift; re-evaluate both this canary and the no-lift rule.
|
||||
foreach (bool lift in new[] { false, true })
|
||||
{
|
||||
var armCells = lift ? new Dictionary<uint, LoadedCell>() : cells;
|
||||
if (lift)
|
||||
{
|
||||
var datDir2 = CornerFloodReplayTests.ResolveDatDir()!;
|
||||
using var dats2 = new DatCollection(datDir2, DatAccessType.Read);
|
||||
var lbi = dats2.Get<DatReaderWriter.DBObjs.LandBlockInfo>(Landblock | 0xFFFEu)!;
|
||||
for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++)
|
||||
{
|
||||
try { armCells[Landblock | low] = CornerFloodReplayTests.LoadCell(dats2, Landblock | low, new Vector3(0f, 0f, 0.02f)); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
}
|
||||
Func<uint, LoadedCell?> armLookup = id => armCells.TryGetValue(id, out var c) ? c : null;
|
||||
var root = armCells[Landblock | 0x010Au];
|
||||
|
||||
foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f })
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(fov, 1280f / 720f, 0.1f, 5000f);
|
||||
var pv = PortalVisibilityBuilder.Build(root, eye, armLookup, view * proj);
|
||||
bool hasMain = pv.CellViews.ContainsKey(MainCell);
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$"lift={lift} fov={fov:F2}: flood={pv.OrderedVisibleCells.Count} hasMain={hasMain}"));
|
||||
if (!lift)
|
||||
Assert.True(hasMain,
|
||||
$"fov={fov:F2}: the tower main cell 0x{MainCell:X8} is NOT in the flood from the roof-lip root (unlifted = post-fix production)");
|
||||
else
|
||||
Assert.False(hasMain,
|
||||
$"fov={fov:F2}: the LIFTED canary unexpectedly admits the main cell — the in-plane window now absorbs a 2 cm lift; re-evaluate the canary + the no-lift rule");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate-by-gate diagnosis of the captured drop: for the 010A→0107 portal
|
||||
/// at the exact captured eye/gaze, print each admission gate's verdict —
|
||||
/// side test (CameraOnInteriorSide replica), homogeneous clip vs the
|
||||
/// full-screen view (ProjectToClip + ClipToRegion, the forward-hop
|
||||
/// pipeline), and the eye-in-opening rescue inputs (perp distance +
|
||||
/// footprint containment).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_TopOfStairs_GateByGate()
|
||||
{
|
||||
var datDir = RequireDatDir();
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock);
|
||||
var root = cells[Landblock | 0x010Au];
|
||||
|
||||
// capture eye converted to the cells' AAB3-local frame (world - (192,-192,0))
|
||||
var eye = new Vector3(301.448f - 192f, -128.448f + 192f, 126.803f);
|
||||
var fwd = Vector3.Normalize(new Vector3(-0.9361f, -0.2459f, -0.2514f));
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1280f / 720f, 0.1f, 5000f);
|
||||
var viewProj = view * proj;
|
||||
|
||||
for (int i = 0; i < root.Portals.Count; i++)
|
||||
{
|
||||
var portal = root.Portals[i];
|
||||
var poly = i < root.PortalPolygons.Count ? root.PortalPolygons[i] : null;
|
||||
_out.WriteLine($"portal[{i}] -> 0x{portal.OtherCellId:X4} polyLen={poly?.Length ?? -1}");
|
||||
if (poly is null || poly.Length < 3) continue;
|
||||
|
||||
// gate 1: side test (CameraOnInteriorSide replica, ε=0.01 in-plane-allowed)
|
||||
string side = "no-plane(allow)";
|
||||
if (i < root.ClipPlanes.Count && root.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
|
||||
{
|
||||
var plane = root.ClipPlanes[i];
|
||||
var localCam = Vector3.Transform(eye, root.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
||||
bool pass = plane.InsideSide == 0 ? dot >= -0.01f : dot <= 0.01f;
|
||||
side = FormattableString.Invariant($"dot={dot:F4} insideSide={plane.InsideSide} -> {(pass ? "TRAVERSE" : "CULL")}");
|
||||
}
|
||||
_out.WriteLine($" side: {side}");
|
||||
|
||||
// gate 2: homogeneous clip vs the full-screen view
|
||||
var clip = PortalProjection.ProjectToClip(poly, root.WorldTransform, viewProj);
|
||||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
var region = clip.Length >= 3 ? PortalProjection.ClipToRegion(clip, fullScreen) : Array.Empty<Vector2>();
|
||||
_out.WriteLine($" clip: projVerts={clip.Length} regionVerts={region.Length}");
|
||||
|
||||
// gate 3: rescue inputs — perp distance to the portal plane + the
|
||||
// perpendicular foot inside the opening's footprint
|
||||
var centroid = Vector3.Zero;
|
||||
foreach (var v in poly) centroid += Vector3.Transform(v, root.WorldTransform);
|
||||
centroid /= poly.Length;
|
||||
var n0 = Vector3.Transform(poly[0], root.WorldTransform);
|
||||
var n1 = Vector3.Transform(poly[1], root.WorldTransform);
|
||||
var n2 = Vector3.Transform(poly[2], root.WorldTransform);
|
||||
var planeN = Vector3.Normalize(Vector3.Cross(n1 - n0, n2 - n0));
|
||||
float perp = MathF.Abs(Vector3.Dot(planeN, eye - centroid));
|
||||
_out.WriteLine(FormattableString.Invariant(
|
||||
$" rescue: perpDist={perp:F4} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) planeN=({planeN.X:F2},{planeN.Y:F2},{planeN.Z:F2})"));
|
||||
}
|
||||
|
||||
// knife-edge sweep: the probe rounds the eye to mm — sweep z within the
|
||||
// rounding envelope and count clip flips for the 010A→0107 portal. A
|
||||
// chaotic 6↔0 region inside ±2 mm proves the live drop is the same
|
||||
// frame at sub-mm precision.
|
||||
{
|
||||
var poly = root.PortalPolygons[0];
|
||||
var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
int flips = 0;
|
||||
bool? prev = null;
|
||||
var results = new System.Text.StringBuilder();
|
||||
for (int i = -20; i <= 20; i++)
|
||||
{
|
||||
var e2 = eye with { Z = eye.Z + i * 0.0002f };
|
||||
var v2 = Matrix4x4.CreateLookAt(e2, e2 + fwd * 3f, Vector3.UnitZ);
|
||||
var vp2 = v2 * proj;
|
||||
var clip2 = PortalProjection.ProjectToClip(poly, root.WorldTransform, vp2);
|
||||
var region2 = clip2.Length >= 3 ? PortalProjection.ClipToRegion(clip2, fullScreen) : Array.Empty<Vector2>();
|
||||
bool ok = region2.Length >= 3;
|
||||
if (prev.HasValue && ok != prev.Value) flips++;
|
||||
prev = ok;
|
||||
results.Append(ok ? '1' : '0');
|
||||
}
|
||||
_out.WriteLine($" knife-edge sweep (z ±4 mm, 0.2 mm steps): {results} flips={flips}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Full per-step table for the investigation record.</summary>
|
||||
[Fact]
|
||||
[Trait("Purpose", "Diagnostic")]
|
||||
public void Diagnostic_TowerAscent_PerStepTable()
|
||||
{
|
||||
var steps = RunAscent();
|
||||
DumpSteps(steps);
|
||||
}
|
||||
|
||||
private static string RequireDatDir()
|
||||
=> CornerFloodReplayTests.ResolveDatDir()
|
||||
?? throw new InvalidOperationException(
|
||||
"Installed client_portal.dat is required for the tower-ascent lane.");
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class ViewconeCullerReuseTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReusedCuller_ReplacesPriorCellAndOutsideState()
|
||||
{
|
||||
const uint firstCell = 0xA9B40100;
|
||||
const uint secondCell = 0xA9B40101;
|
||||
var clipFrame = ClipFrame.NoClip();
|
||||
var assemblyScratch = new ClipFrameAssembly();
|
||||
var culler = new ViewconeCuller();
|
||||
|
||||
var first = Frame(firstCell, includeOutside: true);
|
||||
ClipFrameAssembly firstAssembly = ClipFrameAssembler.Assemble(
|
||||
clipFrame,
|
||||
first,
|
||||
assemblyScratch);
|
||||
Assert.Same(culler, ViewconeCuller.Build(firstAssembly, Matrix4x4.Identity, culler));
|
||||
Assert.True(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f));
|
||||
Assert.True(culler.SphereVisibleOutside(Vector3.Zero, 0f));
|
||||
|
||||
var second = Frame(secondCell, includeOutside: false);
|
||||
ClipFrameAssembly secondAssembly = ClipFrameAssembler.Assemble(
|
||||
clipFrame,
|
||||
second,
|
||||
assemblyScratch);
|
||||
Assert.Same(culler, ViewconeCuller.Build(secondAssembly, Matrix4x4.Identity, culler));
|
||||
|
||||
Assert.False(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f));
|
||||
Assert.True(culler.SphereVisibleInCell(secondCell, Vector3.Zero, 0f));
|
||||
Assert.False(culler.SphereVisibleInCell(secondCell, new Vector3(2f, 0f, 0f), 0f));
|
||||
Assert.False(culler.SphereVisibleOutside(Vector3.Zero, 0f));
|
||||
}
|
||||
|
||||
private static PortalVisibilityFrame Frame(uint cellId, bool includeOutside)
|
||||
{
|
||||
var result = new PortalVisibilityFrame();
|
||||
var cellView = new CellView();
|
||||
cellView.Add(Square(0.5f));
|
||||
result.CellViews.Add(cellId, cellView);
|
||||
result.OrderedVisibleCells.Add(cellId);
|
||||
if (includeOutside)
|
||||
result.OutsideView.Add(Square(0.5f));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ViewPolygon Square(float half)
|
||||
=> new(new[]
|
||||
{
|
||||
new Vector2(-half, -half),
|
||||
new Vector2(half, -half),
|
||||
new Vector2(half, half),
|
||||
new Vector2(-half, half),
|
||||
});
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ public sealed class WalkBuildingInstalledDatCensusTests
|
|||
[Fact]
|
||||
public void CompleteInstalledBuildingCorpusMatchesBoundedSelectionCensus()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
using var dats = new DatCollection(datDir!, DatAccessType.Read);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ public sealed class WalkBuildingJoinDiagnosticTests
|
|||
public void Traced_lookin_cells_are_reachable_through_the_building_portal_table(
|
||||
uint buildingCellId, uint[] tracedLookInCells)
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
|
|||
|
|
@ -446,7 +446,7 @@ public sealed partial class WalkFrameDriverTests
|
|||
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
|
||||
|
||||
// Exit seals replay through the exact portal_view slices captured by
|
||||
// this walk. The legacy PortalVisibilityFrame is not consulted.
|
||||
// this walk; there is no independently reconstructed visibility graph.
|
||||
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(0));
|
||||
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1));
|
||||
Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(0, 0).Length);
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ namespace AcDream.App.Tests.Rendering.Walk;
|
|||
/// <see cref="RenderingDiagnostics.DumpWalkTranscriptEnabled"/> and
|
||||
/// <see cref="Console.Out"/> are both process-wide mutable statics — joins
|
||||
/// <see cref="CameraDiagnosticsCollection"/> for the SAME reason
|
||||
/// <c>CornerFloodReplayTests</c>/<c>Issue181WallPressEquilibriumTests</c> do
|
||||
/// (that collection's own doc comment, issue #251): interleaved
|
||||
/// <c>Issue181WallPressEquilibriumTests</c> does (that collection's own doc
|
||||
/// comment, issue #251): interleaved
|
||||
/// <c>Console.SetOut</c> redirection across parallel test classes can
|
||||
/// restore a disposed <see cref="StringWriter"/> process-wide.
|
||||
/// </para>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public sealed class WalkLookInGateSweepTests
|
|||
+ "(the doorway-still fixture tests it in isolation).")]
|
||||
public void Sweep_the_lookin_gate_decodes_against_the_street_fixture()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public sealed class WalkPortalGateDumpTests
|
|||
[Fact]
|
||||
public void Dump_the_gate_inputs_for_a_punching_and_a_silent_building()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
@ -102,7 +102,7 @@ public sealed class WalkPortalGateDumpTests
|
|||
[Fact]
|
||||
public void Dump_the_exit_view_look_in_inputs_for_walkout_f2()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
@ -232,7 +232,7 @@ public sealed class WalkPortalGateDumpTests
|
|||
[Fact]
|
||||
public void Dump_the_foundry_f67_bsp_traversal_for_0036()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory.");
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ public sealed class WalkProductionWorldConformanceTests
|
|||
|
||||
private static DatCollection OpenDats()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
|
|
|||
|
|
@ -453,31 +453,6 @@ public sealed class WalkProductionWorldDataTests
|
|||
Assert.Equal(first.Records.Count, second.Records.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaticBucketContains_FindsARegisteredSourceIdInItsRetailCell()
|
||||
{
|
||||
const uint entityId = 0x48A02040u;
|
||||
const uint sourceId = 0x020009A2u;
|
||||
const uint retailCellId = 0xF4180112u;
|
||||
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
Register(shadows, entityId, retailCellId, landblockId: 0xF4180000u);
|
||||
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
|
||||
using var scene = new ArchRenderScene(generation);
|
||||
RenderProjectionRecord projection =
|
||||
IndoorStaticRecord(entityId, sourceId, retailCellId);
|
||||
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
|
||||
|
||||
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
|
||||
worldData.BeginFrame(
|
||||
scene.OpenQuery(), 0xF4180000u, renderCenterLbX: 0xF4, renderCenterLbY: 0x18);
|
||||
|
||||
Assert.True(worldData.StaticBucketContains(retailCellId, sourceId));
|
||||
Assert.False(worldData.StaticBucketContains(retailCellId, sourceId + 1));
|
||||
Assert.False(worldData.StaticBucketContains(0xF4180107u, sourceId));
|
||||
}
|
||||
|
||||
private static RenderProjectionRecord Record(uint id, Vector3 position) =>
|
||||
new RenderProjectionRecord() with
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Content;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Walk;
|
||||
|
||||
public sealed class WalkRendererArchitectureGuardTests
|
||||
{
|
||||
private static readonly Assembly AppAssembly = typeof(RetailFrameWalk).Assembly;
|
||||
|
||||
private static readonly string[] DeletedGraphTypes =
|
||||
[
|
||||
"PortalVisibilityBuilder",
|
||||
"PortalVisibilityFrame",
|
||||
"ExteriorPortalSeed",
|
||||
"IndoorDrawPlan",
|
||||
"CellDrawEntry",
|
||||
"ViewconeCuller",
|
||||
];
|
||||
|
||||
private static readonly string[] DeletedProbeSymbols =
|
||||
[
|
||||
"ProbeFacilityStairsEnabled",
|
||||
"ACDREAM_PROBE_FACILITY_STAIRS",
|
||||
"ProbeIndoorWalkEnabled",
|
||||
"ProbeIndoorLookupEnabled",
|
||||
"ProbeIndoorUploadEnabled",
|
||||
"ProbeIndoorXformEnabled",
|
||||
"ProbeIndoorCullEnabled",
|
||||
"ACDREAM_PROBE_INDOOR_WALK",
|
||||
"ACDREAM_PROBE_INDOOR_LOOKUP",
|
||||
"ACDREAM_PROBE_INDOOR_UPLOAD",
|
||||
"ACDREAM_PROBE_INDOOR_XFORM",
|
||||
"ACDREAM_PROBE_INDOOR_CULL",
|
||||
"ACDREAM_PROBE_INDOOR_ALL",
|
||||
"ProbeVisibilityEnabled",
|
||||
"ACDREAM_PROBE_VIS",
|
||||
"EmitVis",
|
||||
"ProbeEnvCellEnabled",
|
||||
"ACDREAM_PROBE_ENVCELL",
|
||||
"ProbeFlapEnabled",
|
||||
"ACDREAM_PROBE_FLAP",
|
||||
"ProbePvInputEnabled",
|
||||
"ACDREAM_PROBE_PVINPUT",
|
||||
"ProbeGlStateEnabled",
|
||||
"ACDREAM_PROBE_GLSTATE",
|
||||
"ProbeClipRouteEnabled",
|
||||
"ACDREAM_PROBE_CLIPROUTE",
|
||||
"ProbePortalChurnEnabled",
|
||||
"ACDREAM_PROBE_PORTAL_CHURN",
|
||||
"ProbeIndoorLightEnabled",
|
||||
"ACDREAM_PROBE_INDOOR_LIGHT",
|
||||
"EmitIndoorLight",
|
||||
"ProbeSeamDrawEnabled",
|
||||
"ACDREAM_PROBE_SEAMDRAW",
|
||||
"SeamDrawTargetCells",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void AppAssembly_ContainsNoSupersededPortalGraphTypes()
|
||||
{
|
||||
string[] survivors = AppAssembly.GetTypes()
|
||||
.Where(type => DeletedGraphTypes.Contains(type.Name, StringComparer.Ordinal))
|
||||
.Select(type => type.FullName ?? type.Name)
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Empty(survivors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkFrameOwners_AreUnique()
|
||||
{
|
||||
const BindingFlags Fields = BindingFlags.Instance | BindingFlags.Public |
|
||||
BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
|
||||
|
||||
Type[] sourceTypes = AppAssembly.GetTypes()
|
||||
.Where(type => !type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false))
|
||||
.ToArray();
|
||||
(Type Owner, FieldInfo Field)[] pviewOwners = sourceTypes
|
||||
.SelectMany(type => type.GetFields(Fields).Select(field => (type, field)))
|
||||
.Where(pair => pair.field.FieldType == typeof(WalkPView))
|
||||
.Select(pair => (pair.type, pair.field))
|
||||
.ToArray();
|
||||
Assert.All(pviewOwners, pair => Assert.Equal(typeof(RetailFrameWalk), pair.Owner));
|
||||
Assert.Equal(2, pviewOwners.Length);
|
||||
|
||||
(Type Owner, FieldInfo Field)[] frameWalkOwners = sourceTypes
|
||||
.SelectMany(type => type.GetFields(Fields).Select(field => (type, field)))
|
||||
.Where(pair => pair.field.FieldType == typeof(RetailFrameWalk))
|
||||
.Select(pair => (pair.type, pair.field))
|
||||
.ToArray();
|
||||
(Type Owner, FieldInfo Field) owner = Assert.Single(frameWalkOwners);
|
||||
Assert.Equal(typeof(RetailPViewRenderer), owner.Owner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameTimeWalkOwners_HaveNoRawDatDependency()
|
||||
{
|
||||
Type[] owners =
|
||||
[
|
||||
typeof(RetailFrameWalk),
|
||||
typeof(WalkFrameDriver),
|
||||
typeof(RetailPViewRenderer),
|
||||
typeof(RetailPViewPassExecutor),
|
||||
typeof(WalkProductionLeafRenderer),
|
||||
typeof(WalkStaticStreamPopulator),
|
||||
typeof(WbDrawDispatcher),
|
||||
];
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (Type owner in owners)
|
||||
{
|
||||
const BindingFlags Members = BindingFlags.Instance | BindingFlags.Static |
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
|
||||
foreach (FieldInfo field in owner.GetFields(Members))
|
||||
AddIfRawDat(violations, owner, $"field {field.Name}", field.FieldType);
|
||||
foreach (PropertyInfo property in owner.GetProperties(Members))
|
||||
AddIfRawDat(violations, owner, $"property {property.Name}", property.PropertyType);
|
||||
|
||||
IEnumerable<MethodBase> methods = owner.GetMethods(Members).Cast<MethodBase>()
|
||||
.Concat(owner.GetConstructors(Members));
|
||||
foreach (MethodBase method in methods)
|
||||
{
|
||||
foreach (ParameterInfo parameter in method.GetParameters())
|
||||
{
|
||||
AddIfRawDat(
|
||||
violations,
|
||||
owner,
|
||||
$"parameter {method.Name}.{parameter.Name}",
|
||||
parameter.ParameterType);
|
||||
}
|
||||
|
||||
if (method is MethodInfo methodInfo)
|
||||
AddIfRawDat(violations, owner, $"return {method.Name}", methodInfo.ReturnType);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionSource_ContainsNoSecondPortalGraph()
|
||||
{
|
||||
string[] sources = ProductionSources();
|
||||
string[] banned = [.. DeletedGraphTypes, "ClipFrameAssembler.Assemble"];
|
||||
AssertNoSourceMatches(sources, banned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderedWalkStream_HasNoCrossStreamReorder()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src", "AcDream.App", "Rendering", "Wb", "WbDrawDispatcher.OrderedStream.cs"));
|
||||
|
||||
Assert.DoesNotContain(".Sort(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("OrderBy(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("OrderByDescending(", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionSource_ContainsNoDeletedRendererProbe_AndRetainsWalkTranscriptProof()
|
||||
{
|
||||
AssertNoSourceMatches(ProductionSources(), DeletedProbeSymbols);
|
||||
|
||||
string root = FindRepoRoot();
|
||||
string diagnostics = File.ReadAllText(Path.Combine(
|
||||
root, "src", "AcDream.Core", "Rendering", "RenderingDiagnostics.cs"));
|
||||
string transcriptTests = File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"tests", "AcDream.App.Tests", "Rendering", "Walk",
|
||||
"WalkFrameDriverTranscriptTests.cs"));
|
||||
Assert.Contains("DumpWalkTranscriptEnabled", diagnostics, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"TranscriptEmitter_FlagOff_EveryPrintMethodIsAZeroCostNoOp",
|
||||
transcriptTests,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Collect_TranscriptFlagOn_PrintedLinesRoundTripThroughTheParser",
|
||||
transcriptTests,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void AddIfRawDat(
|
||||
ICollection<string> violations,
|
||||
Type owner,
|
||||
string member,
|
||||
Type memberType)
|
||||
{
|
||||
if (ContainsRawDat(memberType))
|
||||
violations.Add($"{owner.FullName} {member}: {memberType}");
|
||||
}
|
||||
|
||||
private static bool ContainsRawDat(Type type)
|
||||
{
|
||||
if (type.IsByRef || type.IsArray || type.IsPointer)
|
||||
return ContainsRawDat(type.GetElementType()!);
|
||||
if (type == typeof(DatCollection) || type == typeof(IDatReaderWriter))
|
||||
return true;
|
||||
return type.IsGenericType && type.GetGenericArguments().Any(ContainsRawDat);
|
||||
}
|
||||
|
||||
private static void AssertNoSourceMatches(IEnumerable<string> sources, IEnumerable<string> banned)
|
||||
{
|
||||
string[] violations = sources
|
||||
.SelectMany(path => banned
|
||||
.Where(symbol => File.ReadAllText(path).Contains(symbol, StringComparison.Ordinal))
|
||||
.Select(symbol => $"{Path.GetRelativePath(FindRepoRoot(), path)}: {symbol}"))
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
|
||||
private static string[] ProductionSources()
|
||||
{
|
||||
string root = Path.Combine(FindRepoRoot(), "src");
|
||||
return Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
string? directory = AppContext.BaseDirectory;
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
|
||||
return directory;
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
|
|
@ -318,7 +318,7 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
Assert.DoesNotContain(
|
||||
"one stable far-to-near stream keyed", architecture, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Contains("active total to **161**", register, StringComparison.Ordinal);
|
||||
Assert.Contains("current active total to **160**", register, StringComparison.Ordinal);
|
||||
int apSectionStart = register.IndexOf(
|
||||
"## 3. Documented approximation (AP)", StringComparison.Ordinal);
|
||||
int apSectionEnd = register.IndexOf(
|
||||
|
|
@ -342,7 +342,7 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
Assert.Single(File.ReadLines(registerPath), static line =>
|
||||
line.StartsWith("| AP-244 |", StringComparison.Ordinal));
|
||||
Assert.Equal(
|
||||
161,
|
||||
160,
|
||||
File.ReadLines(registerPath).Count(static line =>
|
||||
line.StartsWith("| AP-", StringComparison.Ordinal)));
|
||||
Assert.Contains("greater than 50 m", register, StringComparison.Ordinal);
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ public sealed partial class WalkTraceConformanceTests
|
|||
|
||||
private static DatCollection OpenDats()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public sealed class WalkWorldDatAdapterTests
|
|||
{
|
||||
private static DatCollection OpenDats()
|
||||
{
|
||||
string? datDir = CornerFloodReplayTests.ResolveDatDir();
|
||||
string? datDir = InstalledDatTestPath.Resolve();
|
||||
if (datDir is null)
|
||||
{
|
||||
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||
|
|
|
|||
|
|
@ -39,21 +39,13 @@ public class WalkOutsideViewReassemblyTests
|
|||
return view;
|
||||
}
|
||||
|
||||
private static ClipFrameAssembly AssembledWithOldOutside()
|
||||
{
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.OutsideView.Add(new ViewPolygon(new[]
|
||||
{
|
||||
new Vector2(-0.9f, -0.9f), new Vector2(0.9f, -0.9f),
|
||||
new Vector2(0.9f, 0.9f), new Vector2(-0.9f, 0.9f),
|
||||
}));
|
||||
return ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
}
|
||||
private static ClipFrameAssembly BeginAssembly()
|
||||
=> ClipFrameAssembler.BeginWalkFrame(ClipFrame.NoClip(), outdoorRoot: true);
|
||||
|
||||
[Fact]
|
||||
public void PixelDoorway_MapsToExpectedNdcAabbAndPlanes()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
// Pixel quad x∈[256,512], y∈[180,360] → NDC x∈[-0.5,0], y∈[0,0.5]
|
||||
// (yNdc = 1 − 2·py/H flips the axis: py=180 → +0.5, py=360 → 0).
|
||||
WalkPortalView walkView = WalkViewOfPixelQuads(new[]
|
||||
|
|
@ -95,7 +87,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void FullViewportWalkQuad_CoversFullNdc()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
var walkView = new WalkPortalView();
|
||||
Assert.True(WalkCopyView.AppendFullViewportQuad(
|
||||
walkView, new StubRays(), Vector3.Zero, W, H));
|
||||
|
|
@ -109,8 +101,8 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void EmptyWalkView_YieldsSkipMode()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
Assert.True(asm.OutdoorVisible); // the old view was visible pre-cutover
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
|
||||
ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, new WalkPortalView(), W, H);
|
||||
|
||||
|
|
@ -138,7 +130,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
|
||||
var walkView = new WalkPortalView();
|
||||
// View 0: three EXACTLY collinear pixel points -> zero-area triangle,
|
||||
|
|
@ -196,7 +188,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
var walkView = new WalkPortalView();
|
||||
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(100f, 100f) });
|
||||
walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(200f, 100f) });
|
||||
|
|
@ -356,7 +348,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
|
||||
const int n = 9;
|
||||
var verts = new Vector2[n];
|
||||
|
|
@ -387,7 +379,7 @@ public class WalkOutsideViewReassemblyTests
|
|||
[Fact]
|
||||
public void TwoWalkViews_ProduceTwoSlicesWithDistinctSlots()
|
||||
{
|
||||
ClipFrameAssembly asm = AssembledWithOldOutside();
|
||||
ClipFrameAssembly asm = BeginAssembly();
|
||||
WalkPortalView walkView = WalkViewOfPixelQuads(
|
||||
new[]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,194 +1,29 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Vfx;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class WorldRenderDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GlTripwire_DisabledPerformsNoGlRead()
|
||||
{
|
||||
var gl = new RecordingGlStateReader();
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
||||
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: false);
|
||||
|
||||
Assert.Equal(0, gl.StateCaptureCount);
|
||||
Assert.Empty(log.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlTripwire_LogsOnlyChangesAndReportsStableFrameCount()
|
||||
{
|
||||
var gl = new RecordingGlStateReader
|
||||
{
|
||||
State = State(depthFunction: 0x201),
|
||||
};
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
||||
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
||||
gl.State = State(depthFunction: 0x203);
|
||||
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
||||
|
||||
Assert.Equal(3, gl.StateCaptureCount);
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
first =>
|
||||
{
|
||||
Assert.StartsWith("[gl-state] frame=1 stable=0", first);
|
||||
Assert.Contains("dfunc=0x201", first);
|
||||
},
|
||||
changed =>
|
||||
{
|
||||
Assert.StartsWith("[gl-state] frame=3 stable=1", changed);
|
||||
Assert.Contains("dfunc=0x203", changed);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScissorProbe_SequenceAdvancesAcrossSuppressedDuplicates()
|
||||
{
|
||||
var gl = new RecordingGlStateReader
|
||||
{
|
||||
Scissor = new RenderGlScissorSnapshot(
|
||||
true,
|
||||
new IntRenderRectangle(1, 2, 3, 4)),
|
||||
};
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
||||
|
||||
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
||||
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
||||
gl.Scissor = new RenderGlScissorSnapshot(
|
||||
true,
|
||||
new IntRenderRectangle(5, 6, 7, 8));
|
||||
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
||||
|
||||
Assert.Equal(3, gl.ScissorCaptureCount);
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
first => Assert.StartsWith("[clip-route-scis] n=1", first),
|
||||
changed => Assert.StartsWith("[clip-route-scis] n=3", changed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Owner_RetainsNoBorrowedFrameProductsOrBroadRuntimeOwners()
|
||||
{
|
||||
FieldInfo[] fields = typeof(WorldRenderDiagnostics).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Type[] forbidden =
|
||||
[
|
||||
typeof(GameWindow),
|
||||
typeof(PortalVisibilityFrame),
|
||||
typeof(ClipFrameAssembly),
|
||||
typeof(InteriorEntityPartition.Result),
|
||||
typeof(AcDream.Core.Physics.PhysicsEngine),
|
||||
typeof(CameraController),
|
||||
typeof(CellVisibility),
|
||||
];
|
||||
foreach (Type type in forbidden)
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == type);
|
||||
Assert.DoesNotContain(
|
||||
fields,
|
||||
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||
}
|
||||
|
||||
// Campaign V slice V11 deleted SilkRenderGlStateReader, the raw-GL
|
||||
// IRenderGlStateReader implementation this test pinned (it was never
|
||||
// constructed in production — NullRenderGlStateReader.Instance is the
|
||||
// sole surviving implementer), so the "consume the entering GL error
|
||||
// before other state reads" source-order tripwire it protected has
|
||||
// nothing left to pin.
|
||||
|
||||
[Fact]
|
||||
public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples()
|
||||
{
|
||||
var log = new ThrowOnceLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
||||
var facts = new TerrainRenderDiagnosticFacts(VisibleSlots: 3, Draws: 3, LoadedSlots: 5, CapacitySlots: 8);
|
||||
var diagnostics = new WorldRenderDiagnostics(log);
|
||||
var facts = new TerrainRenderDiagnosticFacts(3, 3, 5, 8);
|
||||
|
||||
diagnostics.BeginTerrainDraw();
|
||||
diagnostics.EndTerrainDraw();
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
diagnostics.PublishTerrainDiagnostics(facts));
|
||||
Assert.Throws<InvalidOperationException>(() => diagnostics.PublishTerrainDiagnostics(facts));
|
||||
|
||||
diagnostics.BeginTerrainDraw();
|
||||
diagnostics.EndTerrainDraw();
|
||||
diagnostics.PublishTerrainDiagnostics(facts);
|
||||
|
||||
Assert.Single(log.Messages);
|
||||
Assert.Contains("draws=3/frame", log.Messages[0]);
|
||||
Assert.Contains("visible=3", log.Messages[0]);
|
||||
Assert.Contains("loaded=5", log.Messages[0]);
|
||||
Assert.Contains("capacity=8", log.Messages[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderSignature_LogsOnlyChangesAndCarriesStableCount()
|
||||
{
|
||||
var log = new RecordingLog();
|
||||
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
||||
|
||||
EmitMinimalSignature(diagnostics, "flat");
|
||||
EmitMinimalSignature(diagnostics, "flat");
|
||||
EmitMinimalSignature(diagnostics, "pview");
|
||||
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
first => Assert.StartsWith("[render-sig] frame=1 stable=0 branch=flat", first),
|
||||
changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed));
|
||||
}
|
||||
|
||||
private static RenderGlStateSnapshot State(int depthFunction) => new(
|
||||
DepthTest: true,
|
||||
DepthWrite: true,
|
||||
DepthFunction: depthFunction,
|
||||
Blend: false,
|
||||
BlendSource: 1,
|
||||
BlendDestination: 0,
|
||||
CullFace: true,
|
||||
CullMode: 0x405,
|
||||
FrontFace: 0x900,
|
||||
Scissor: false,
|
||||
ScissorBox: new IntRenderRectangle(0, 0, 1280, 720),
|
||||
Viewport: new IntRenderRectangle(0, 0, 1280, 720),
|
||||
DrawFramebuffer: 0,
|
||||
AlphaToCoverage: false,
|
||||
Stencil: false,
|
||||
ClipBits: 0,
|
||||
Error: 0);
|
||||
|
||||
private sealed class RecordingGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot State { get; set; } = WorldRenderDiagnosticsTests.State(0x201);
|
||||
public RenderGlScissorSnapshot Scissor { get; set; }
|
||||
public int StateCaptureCount { get; private set; }
|
||||
public int ScissorCaptureCount { get; private set; }
|
||||
|
||||
public RenderGlStateSnapshot CaptureState()
|
||||
{
|
||||
StateCaptureCount++;
|
||||
return State;
|
||||
}
|
||||
|
||||
public RenderGlScissorSnapshot CaptureScissor()
|
||||
{
|
||||
ScissorCaptureCount++;
|
||||
return Scissor;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public void WriteLine(string message) => Messages.Add(message);
|
||||
string message = Assert.Single(log.Messages);
|
||||
Assert.Contains("draws=3/frame", message);
|
||||
Assert.Contains("visible=3", message);
|
||||
Assert.Contains("loaded=5", message);
|
||||
Assert.Contains("capacity=8", message);
|
||||
}
|
||||
|
||||
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
|
||||
|
|
@ -207,33 +42,4 @@ public sealed class WorldRenderDiagnosticsTests
|
|||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EmitMinimalSignature(
|
||||
WorldRenderDiagnostics diagnostics,
|
||||
string branch) =>
|
||||
diagnostics.EmitRenderSignatureIfChanged(
|
||||
enabled: true,
|
||||
branch: branch,
|
||||
clipRoot: null,
|
||||
viewerRoot: null,
|
||||
playerRoot: null,
|
||||
viewerCellId: 0,
|
||||
playerCellId: 0,
|
||||
playerIndoorGate: false,
|
||||
cameraInsideCell: false,
|
||||
renderSkyGate: false,
|
||||
drawSkyThisFrame: false,
|
||||
terrainDrawn: false,
|
||||
terrainClipMode: TerrainClipMode.Skip,
|
||||
skyDrawn: false,
|
||||
depthClear: false,
|
||||
outdoorSceneryDrawn: false,
|
||||
liveDynamicDrawnCount: 0,
|
||||
sceneParticles: "none",
|
||||
visibleCells: null,
|
||||
clipAssembly: null,
|
||||
drawableCells: null,
|
||||
partition: null,
|
||||
cameraPosition: Vector3.Zero,
|
||||
playerPosition: Vector3.Zero);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,6 @@ public sealed class WorldSceneRendererTests
|
|||
"passes:disable-clip",
|
||||
"particles:global",
|
||||
"flat:weather",
|
||||
"diagnostics:signature:OutdoorRoot",
|
||||
"diagnostics:draw",
|
||||
"alpha:end",
|
||||
"visibility:complete",
|
||||
|
|
@ -238,10 +237,8 @@ public sealed class WorldSceneRendererTests
|
|||
"passes:begin",
|
||||
"pview:draw",
|
||||
"visibility:mark",
|
||||
"diagnostics:pview",
|
||||
"passes:disable-clip",
|
||||
"particles:none",
|
||||
"diagnostics:signature:RetailPViewInside",
|
||||
"particles:skipped",
|
||||
"diagnostics:draw",
|
||||
"alpha:end",
|
||||
"visibility:complete",
|
||||
|
|
@ -307,7 +304,7 @@ public sealed class WorldSceneRendererTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
|
||||
public void OutdoorPView_SkipsPostWorldParticleReplayAndFlatWeather()
|
||||
{
|
||||
var root = new LoadedCell
|
||||
{
|
||||
|
|
@ -319,7 +316,7 @@ public sealed class WorldSceneRendererTests
|
|||
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
|
||||
|
||||
Assert.True(result.NormalWorldDrawn);
|
||||
Assert.Contains("particles:pviewScoped", rig.Calls);
|
||||
Assert.Contains("particles:skipped", rig.Calls);
|
||||
Assert.DoesNotContain("flat:weather", rig.Calls);
|
||||
}
|
||||
|
||||
|
|
@ -498,7 +495,6 @@ public sealed class WorldSceneRendererTests
|
|||
Type[] borrowedTypes =
|
||||
[
|
||||
typeof(RetailPViewFrameResult),
|
||||
typeof(PortalVisibilityFrame),
|
||||
typeof(ClipFrameAssembly),
|
||||
];
|
||||
|
||||
|
|
@ -690,9 +686,7 @@ public sealed class WorldSceneRendererTests
|
|||
new WbFrustum());
|
||||
_clipFrame = ClipFrame.NoClip();
|
||||
|
||||
var glDiagnostics = new WorldRenderDiagnostics(
|
||||
new DefaultGlStateReader(),
|
||||
new NullDiagnosticLog());
|
||||
var glDiagnostics = new WorldRenderDiagnostics(new NullDiagnosticLog());
|
||||
var terrainDiagnostics = new TerrainDrawDiagnosticsController(
|
||||
enabled: false,
|
||||
glDiagnostics,
|
||||
|
|
@ -710,7 +704,6 @@ public sealed class WorldSceneRendererTests
|
|||
particleRenderer: null,
|
||||
portalDepthMask: null,
|
||||
alpha,
|
||||
glDiagnostics,
|
||||
terrainDiagnostics);
|
||||
|
||||
const uint landblockId = 0xF4180000u;
|
||||
|
|
@ -859,13 +852,6 @@ public sealed class WorldSceneRendererTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class DefaultGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
||||
private sealed class NullDiagnosticLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public void WriteLine(string message) { }
|
||||
|
|
@ -1093,20 +1079,13 @@ public sealed class WorldSceneRendererTests
|
|||
uint? playerLandblockId,
|
||||
HashSet<uint> animatedEntityIds) => calls.Add("flat:entities");
|
||||
|
||||
public string DrawPostWorldParticles(
|
||||
public void DrawPostWorldParticles(
|
||||
LoadedCell? clipRoot,
|
||||
ClipFrameAssembly? clipAssembly,
|
||||
in WorldCameraFrame camera,
|
||||
string currentSignature)
|
||||
in WorldCameraFrame camera)
|
||||
{
|
||||
string kind = clipRoot switch
|
||||
{
|
||||
null => "global",
|
||||
{ IsOutdoorNode: true } => currentSignature,
|
||||
_ => currentSignature,
|
||||
};
|
||||
string kind = clipRoot is null ? "global" : "skipped";
|
||||
calls.Add($"particles:{kind}");
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void DrawFlatWeather(
|
||||
|
|
@ -1135,37 +1114,6 @@ public sealed class WorldSceneRendererTests
|
|||
{
|
||||
public CameraCellResolution CameraCellResolution => CameraCellResolution.None;
|
||||
|
||||
public void EmitPViewInput(
|
||||
IReadOnlySet<uint> visibleCells,
|
||||
int outsideViewCount,
|
||||
Matrix4x4 viewProjection,
|
||||
LoadedCell clipRoot,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition) => calls.Add("diagnostics:pview");
|
||||
|
||||
public void EmitRenderSignature(
|
||||
string branch,
|
||||
LoadedCell? clipRoot,
|
||||
LoadedCell? viewerRoot,
|
||||
LoadedCell? playerRoot,
|
||||
uint viewerCellId,
|
||||
uint playerCellId,
|
||||
bool playerIndoorGate,
|
||||
bool cameraInsideCell,
|
||||
bool renderSky,
|
||||
bool drawSkyThisFrame,
|
||||
bool terrainDrawn,
|
||||
TerrainClipMode terrainClipMode,
|
||||
bool skyDrawn,
|
||||
bool depthClear,
|
||||
bool outdoorSceneryDrawn,
|
||||
int liveDynamicDrawnCount,
|
||||
string sceneParticles,
|
||||
RetailPViewFrameResult? pviewResult,
|
||||
Vector3 cameraPosition,
|
||||
Vector3 playerPosition) =>
|
||||
calls.Add($"diagnostics:signature:{branch}");
|
||||
|
||||
public WorldSceneDiagnosticOutcome DrawAndPublish(
|
||||
in WorldCameraFrame camera,
|
||||
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue