- the LandCell event doc states the deferred cross-block batch rule (the same-landblock lookahead text was stale); - the AlphaSubmitMark arm's comment gives the true reason it is not a flush point (it only enqueues into the CPU alpha list; the drain leaves flush first); - the pending terrain batch is cleared with the other transient frame lists in AbortFrame/BeginFrame; - CompleteWalkTerrainFrame runs in a finally so a throwing Replay cannot leak its accumulated ticks into the next frame's sample; - CopyRenderableEmittersInCell keeps its own doc comment; - the outdoor-root LandCell pin now observes one real cell turn after its terrain; - the oh1 landscape contract's stale "whole pre-stage" prose is resolved. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1879 lines
81 KiB
C#
1879 lines
81 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Collections.ObjectModel;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Gpu.Vk;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Rendering.Walk;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Lib.IO;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Walk;
|
|
|
|
/// <summary>
|
|
/// Campaign FW stage FW3.2b-1: <see cref="WalkFrameDriver"/>'s headless
|
|
/// referee suite — proves that driving <see cref="RetailFrameWalk"/> with the
|
|
/// driver as its <see cref="IWalkEventSink"/> produces retail's own turn
|
|
/// order (all shells then all contents in reverse flood order,
|
|
/// flush-before-every-leaf-action,
|
|
/// content-before-punch) through the REAL <see cref="WbDrawDispatcher.SubmitOrderedStream"/>
|
|
/// onto a <see cref="RecordingGpuDevice"/> — never a mock of the submission
|
|
/// path itself. No production wiring is exercised (<c>WorldSceneRenderer</c>
|
|
/// still does not construct this driver); every world-data/leaf-renderer
|
|
/// dependency here is a synthetic fake per plan §FW3.2b-1.
|
|
/// </summary>
|
|
public sealed class WalkFrameDriverTests
|
|
{
|
|
// ── Shared ordered log: BOTH the fake leaf renderer and the fake trace
|
|
// write into ONE list, so a single sequence assertion proves the FULL
|
|
// interleave (stream flushes interleaved with sky/terrain/shell/punch/
|
|
// alpha-barrier), not just each half in isolation. ─────────────────────
|
|
|
|
private sealed class RecordingLeafRenderer(
|
|
List<string> log,
|
|
RetailAlphaQueue? alpha = null) : IWalkFrameLeafRenderer
|
|
{
|
|
public readonly List<WalkPolygon> Punches = new();
|
|
public readonly List<uint> Shells = new();
|
|
public readonly List<int> AlphaPendingAtBarrier = new();
|
|
public readonly List<(uint LandblockId, int SideCellCount, int CellIndex)[]> LandCellBatches = new();
|
|
|
|
/// <summary>S3 chunk 3 fix round 1 (F2/F4c): cell ids
|
|
/// <see cref="HasRenderableEmittersInCell"/> reports as having NO
|
|
/// renderable emitter. Empty by default so EVERY pre-existing pin in
|
|
/// this file keeps its old "every StaticParticles/CellParticles turn
|
|
/// submits unconditionally" behavior unchanged; a test proving the
|
|
/// new "genuinely empty turn submits nothing and does not flush"
|
|
/// rule opts specific cells OUT via this set.</summary>
|
|
public readonly HashSet<uint> CellsWithoutEmitters = new();
|
|
|
|
/// <summary>S3 chunk 2: the exit-seal polygon count this fake
|
|
/// reports back to the driver (B2 — <see cref="DrawExitSeals"/>
|
|
/// returns the submitted count so the driver can re-arm its
|
|
/// persistent <c>PortalsDrawnCount</c> for the NEXT <c>ov>0</c>
|
|
/// frame's clear decision, R4). Defaults to 1 so a test that
|
|
/// doesn't care about the re-arm mechanism sees an ordinary
|
|
/// "some seal fired" outcome; a test proving "flood with no exit
|
|
/// portal never clears" sets this to 0.</summary>
|
|
public int SealPolygonsSubmitted = 1;
|
|
|
|
public void DrawSky() => log.Add("SKY");
|
|
|
|
public void DrawLandCellBatch(
|
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
|
|
{
|
|
LandCellBatches.Add(cells.ToArray());
|
|
log.Add("LANDCELL:" + string.Join(
|
|
',', cells.Select(c => $"{c.LandblockId:x8}:{c.SideCellCount}:{c.CellIndex}")));
|
|
}
|
|
|
|
public bool HasRenderableEmittersInCell(uint cellId) => !CellsWithoutEmitters.Contains(cellId);
|
|
|
|
public void DrawCellShell(uint cellId)
|
|
{
|
|
Shells.Add(cellId);
|
|
log.Add($"SHELL:{cellId:x8}");
|
|
}
|
|
|
|
public void FlushLandscape() => log.Add("LFLUSH");
|
|
|
|
public void ClearInteriorDepth() => log.Add("CLEAR");
|
|
|
|
public int DrawExitSeals()
|
|
{
|
|
log.Add("SEALS");
|
|
return SealPolygonsSubmitted;
|
|
}
|
|
|
|
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex)
|
|
{
|
|
Punches.Add(worldPolygon);
|
|
log.Add($"PUNCH:{worldPolygon.Vertices.Length}@v{activeViewIndex}");
|
|
}
|
|
|
|
public void AlphaBarrier()
|
|
{
|
|
if (alpha is null)
|
|
{
|
|
log.Add("ALPHA");
|
|
return;
|
|
}
|
|
|
|
AlphaPendingAtBarrier.Add(alpha.PendingCount);
|
|
log.Add($"ALPHA:{alpha.PendingCount}");
|
|
alpha.Flush();
|
|
}
|
|
|
|
public void DrawStaticParticles(uint cellId) => log.Add($"PARTICLES:{cellId:x8}");
|
|
|
|
public void DrawCellParticles(uint cellId) => log.Add($"CELL-PARTICLES:{cellId:x8}");
|
|
}
|
|
|
|
private sealed class RecordingTrace(List<string> log) : IWalkFrameDriverTrace
|
|
{
|
|
public void OnFlush(int commandCount, IReadOnlyList<WalkDrawStage> stages) =>
|
|
log.Add($"FLUSH:{commandCount}:{string.Join(',', stages.Distinct())}");
|
|
}
|
|
|
|
private sealed class FakeWorldData : IWalkFrameWorldData
|
|
{
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new();
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> CellDynamicsByCell = new();
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorStaticsByCell = new();
|
|
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorDynamicsByCell = new();
|
|
public readonly Dictionary<WalkBuilding, WalkFrameStaticRecords> ShellByBuilding = new();
|
|
public readonly Dictionary<WalkBuilding, Matrix4x4> WorldTransformByBuilding = new();
|
|
|
|
public WalkFrameStaticRecords GetCellStatics(uint cellId) =>
|
|
CellStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetCellDynamics(uint cellId) =>
|
|
CellDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) =>
|
|
OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) =>
|
|
OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
|
|
|
|
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) =>
|
|
ShellByBuilding.GetValueOrDefault(building, WalkFrameStaticRecords.Empty);
|
|
|
|
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) =>
|
|
WorldTransformByBuilding.GetValueOrDefault(building, Matrix4x4.Identity);
|
|
}
|
|
|
|
// ── The walk-level test context (interior flood + building portal pass) ─
|
|
|
|
private sealed class Caster : IWalkRayCaster
|
|
{
|
|
public Vector3 RayThrough(float screenX, float screenY) => new(screenX, screenY, 100f);
|
|
}
|
|
|
|
private sealed class TestContext : IWalkFrameContext, IRetailFrameWalkContext
|
|
{
|
|
public readonly Dictionary<uint, WalkCell> Cells = new();
|
|
public readonly Dictionary<WalkBuilding, float> ViewerDistances = new();
|
|
private readonly Matrix4x4 _viewProj;
|
|
private static readonly Vector2[] RootQuad =
|
|
[
|
|
new(0, 480), new(640, 480), new(640, 0), new(0, 0),
|
|
];
|
|
|
|
public TestContext()
|
|
{
|
|
Matrix4x4 view = Matrix4x4.CreateLookAt(
|
|
Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
|
Matrix4x4 proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
|
_viewProj = view * proj;
|
|
}
|
|
|
|
public Vector3 ViewpointIn(WalkCell cell) => Vector3.Zero;
|
|
public Matrix4x4 ObjectToClip(WalkCell cell) => _viewProj;
|
|
public WalkCell? GetVisible(uint cellId) => Cells.GetValueOrDefault(cellId);
|
|
public IWalkRayCaster Rays { get; } = new Caster();
|
|
public Vector3 WorldViewpoint => Vector3.Zero;
|
|
public float ViewportWidth => 640f;
|
|
public float ViewportHeight => 480f;
|
|
|
|
public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero;
|
|
|
|
public float ViewerDistanceTo(WalkBuilding building) =>
|
|
ViewerDistances.GetValueOrDefault(building, 0f);
|
|
|
|
public IWalkFrameContext CellContext => this;
|
|
public WalkPlane CyPlane => new(new Vector3(0, 0, 1), 0f);
|
|
public void SetActiveView(WalkPortalView views, int index) { }
|
|
|
|
public int ClipBuildingPolygon(
|
|
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output)
|
|
{
|
|
Span<WalkScreenPoint> projected = stackalloc WalkScreenPoint[polygon.Vertices.Length];
|
|
for (int i = 0; i < polygon.Vertices.Length; i++)
|
|
projected[i] = WalkScreenClip.TransformToScreen(
|
|
polygon.Vertices[i], _viewProj, ViewportWidth, ViewportHeight);
|
|
if (side != 0)
|
|
projected.Reverse();
|
|
return WalkScreenClip.ClipAgainstView(projected, RootQuad, output);
|
|
}
|
|
}
|
|
|
|
private static WalkPolygon Quad(float z, bool facingViewer = true) => new()
|
|
{
|
|
Vertices =
|
|
[
|
|
new Vector3(-0.5f, -0.5f, z), new Vector3(0.5f, -0.5f, z),
|
|
new Vector3(0.5f, 0.5f, z), new Vector3(-0.5f, 0.5f, z),
|
|
],
|
|
Plane = new WalkPlane(new Vector3(0, 0, facingViewer ? 1f : -1f), facingViewer ? -z : z),
|
|
};
|
|
|
|
// ── Deliverable: RunFrame drives an interior two-cell flood using
|
|
// PView::DrawCells' exact two reverse loops: ALL shells far-to-near,
|
|
// then ALL object cells far-to-near. ─────────────────────────────────
|
|
|
|
// ── Deliverable (S3 chunk 2, superseding the 2026-08-30 decomp
|
|
// correction): PView::DrawCells @0x005a4840's actual DRAW order for an
|
|
// interior root's OWN flood is NOT the order its DrawInside/DrawCells
|
|
// EVENTS fire in (breakpoint-entry order, matching the FW0 oracle
|
|
// traces) — retail draws LScape::draw FIRST (pc:432719, only when exit
|
|
// views survived), then the landscape flush + device-stamp advance,
|
|
// then a GATED depth clear (pc:432731-432732 — R4: read-then-zero
|
|
// portalsDrawnCount @0x005a489c-0x005a489e, clear iff nonzero), then
|
|
// the exit-portal seals (pc:432785-432786), and ONLY THEN the flood's
|
|
// own cells far-to-near. This case has a surviving exit view (ov=1): DC
|
|
// records the flood list (no draw), the landscape turn runs (sky,
|
|
// terrain), THEN flush, THEN — since this is a FRESH driver's very
|
|
// first ov>0 flood, so portalsDrawnCount starts at 0 (R4's
|
|
// "first-frame no-clear quirk") — the clear is SKIPPED, then seals,
|
|
// then all shells and all contents in reverse order. ──────────────────
|
|
|
|
[Fact]
|
|
public void RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObjA = 0x0200_0001UL;
|
|
const ulong gfxObjB = 0x0200_0002UL;
|
|
InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
|
|
MakeBatch(0x08100001u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
|
|
MakeBatch(0x08100002u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
|
|
var ctx = new TestContext();
|
|
var cell1 = new WalkCell
|
|
{
|
|
CellId = 0x100,
|
|
StabList = [0x101u],
|
|
Portals =
|
|
[
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
|
|
},
|
|
// The exit portal (retail's "world beyond the door") — this
|
|
// is what raises ov to 1 and makes DrawInside run the
|
|
// landscape turn before clear+seals+the flood cells.
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1,
|
|
},
|
|
],
|
|
PortalPolygons = [Quad(-2f), Quad(-3f)],
|
|
};
|
|
var cell2 = new WalkCell
|
|
{
|
|
CellId = 0x101,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[cell1.CellId] = cell1;
|
|
ctx.Cells[cell2.CellId] = cell2;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var trace = new RecordingTrace(log);
|
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, leaf, worldData, trace, clipFrame);
|
|
var walk = new RetailFrameWalk();
|
|
// A minimal, no-op landscape (1x1 window, the one slot unpublished)
|
|
// — matches RetailFrameWalkTests' own exit-view fixture. LScape::draw
|
|
// still runs its full turn (sky/terrain) against it; there's simply
|
|
// nothing published to iterate blocks/cells/buildings for.
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.RunFrame(
|
|
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
|
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
// No CLEAR: R4's first-frame no-clear quirk — this driver's
|
|
// PortalsDrawnCount starts at 0, so the read-then-zero
|
|
// decision sees "not armed" even though ov=1.
|
|
"SKY", "LFLUSH", "SEALS",
|
|
"SHELL:00000101", "SHELL:00000100",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
|
},
|
|
log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
// This frame's own seals (RecordingLeafRenderer's default
|
|
// SealPolygonsSubmitted) armed the counter for a NEXT ov>0 frame.
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(2, mdiCalls.Count);
|
|
Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount));
|
|
// Nothing dropped: every populated record reached exactly one indirect draw.
|
|
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.
|
|
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(0));
|
|
Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(1));
|
|
Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(0, 0).Length);
|
|
Assert.Equal(4, driver.InteriorFloodViewClipPlanesAt(1, 0).Length);
|
|
Assert.True(clipFrame.SlotCount >= 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InteriorFloodAfterLandscape_RearmsRetailPartStampForPostClearCellRepaint()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObj = 0x0200_0021UL;
|
|
const uint outdoorCellId = 0xF4180009u;
|
|
const uint interiorCellId = 0xF4180112u;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100021u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
RenderProjectionRecord sharedPart = MakeRecord(
|
|
0x4F41806Cu,
|
|
0,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)]);
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[outdoorCellId] =
|
|
new WalkFrameStaticRecords(new[] { sharedPart }, 0xF418u);
|
|
worldData.CellStaticsByCell[interiorCellId] =
|
|
new WalkFrameStaticRecords(new[] { sharedPart }, 0xF418u);
|
|
|
|
var ctx = new TestContext();
|
|
var interiorCell = new WalkCell { CellId = interiorCellId };
|
|
interiorCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
interiorCell.TopView,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
ctx.Cells[interiorCellId] = interiorCell;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(log),
|
|
worldData,
|
|
new RecordingTrace(log));
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var landscapeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
landscapeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(landscapeViews);
|
|
sink.OnLandscapeCellTurn(outdoorCellId);
|
|
// A landscape turn ran (activeViewCount: 1 above), so this models
|
|
// retail's ov>0 case.
|
|
sink.OnInteriorFloodDrawTurn([interiorCellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(2, mdiCalls.Count);
|
|
Assert.All(mdiCalls, call => Assert.Equal(1u, call.DrawCount));
|
|
Assert.Equal(2, mdiCalls.Sum(call => (int)call.DrawCount));
|
|
Assert.Equal(2, log.Count(entry => entry == "FLUSH:1:OutdoorStatic"
|
|
|| entry == "FLUSH:1:CellStatic"));
|
|
}
|
|
|
|
// ── Deliverable (T1, flipped for S3 chunk 2): the ov==0 interior case —
|
|
// no exit view survives, so DrawInside never runs the landscape turn,
|
|
// and PView::DrawCells' whole landscape-flush/stamp/clear/seal turn
|
|
// (S3 §8.1 R3: all four gated on outside_view.view_count > 0) never
|
|
// runs either — straight to the flood's own cells after the
|
|
// (draw-nothing) DC event. ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeAndNeverFlushesClearsOrSeals()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObjA = 0x0200_0003UL;
|
|
const ulong gfxObjB = 0x0200_0004UL;
|
|
InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
|
|
MakeBatch(0x08100003u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
|
|
MakeBatch(0x08100004u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
|
|
var ctx = new TestContext();
|
|
var cell1 = new WalkCell
|
|
{
|
|
CellId = 0x100,
|
|
StabList = [0x101u],
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
var cell2 = new WalkCell
|
|
{
|
|
CellId = 0x101,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[cell1.CellId] = cell1;
|
|
ctx.Cells[cell2.CellId] = cell2;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var trace = new RecordingTrace(log);
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace);
|
|
var walk = new RetailFrameWalk();
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.RunFrame(
|
|
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: new WalkLandscape(),
|
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
// No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape
|
|
// at all. T1 (flipped): LFLUSH/CLEAR/SEALS and the device-stamp
|
|
// advance are ALSO absent now — S3 §8.1 R3 gates all four on
|
|
// outside_view.view_count > 0, and it is 0 here.
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SHELL:00000101", "SHELL:00000100",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
|
},
|
|
log);
|
|
Assert.Equal(0, driver.PortalsDrawnCount);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(2, mdiCalls.Count);
|
|
Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount));
|
|
// Nothing dropped: every populated record reached exactly one indirect draw.
|
|
Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
|
|
}
|
|
|
|
// ── T2 (S3 chunk 2, R4): a fresh driver's first ov>0 interior flood
|
|
// skips the gated clear (PortalsDrawnCount starts at 0); that same
|
|
// frame's own exit seals arm the counter at Replay, so the driver's
|
|
// SECOND ov>0 flood — even with a different flood cell, since the
|
|
// counter is driver-scoped, not cell-scoped — sees it nonzero and
|
|
// clears. ────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180200u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// R4: PortalsDrawnCount starts at 0 for a fresh driver — the
|
|
// 0x005a489c-0x005a489e read-then-zero decision sees "not armed" on
|
|
// this driver's very first ov>0 interior-root flood, so the gated
|
|
// depth clear (pc:432731-432732) is skipped even though a landscape
|
|
// turn just ran.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var views1 = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
views1, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(views1);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SKY", "LFLUSH", "SEALS",
|
|
"SHELL:f4180200", "CELL-PARTICLES:f4180200",
|
|
},
|
|
log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
log.Clear();
|
|
|
|
// Frame 1's exit seals reported SealPolygonsSubmitted (default 1) at
|
|
// Replay, arming PortalsDrawnCount for THIS frame's read-then-zero.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var views2 = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
views2, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(views2);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SKY", "LFLUSH", "CLEAR", "SEALS",
|
|
"SHELL:f4180200", "CELL-PARTICLES:f4180200",
|
|
},
|
|
log);
|
|
}
|
|
|
|
// ── T2 (S3 chunk 2, R4): a driver whose flood never reaches an exit
|
|
// portal (DrawExitSeals reports 0 submitted every turn) never clears,
|
|
// across any number of ov>0 frames — retail's counter is fed ONLY by
|
|
// actually-submitted seal fans. ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnInteriorFloodDrawTurn_FloodWithNoExitPortal_NeverClearsAcrossFrames()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log) { SealPolygonsSubmitted = 0 };
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180201u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
for (int frame = 0; frame < 3; frame++)
|
|
{
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var views = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
views, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(views);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
}
|
|
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.Equal(3, log.Count(entry => entry == "SEALS"));
|
|
Assert.Equal(0, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// ── T3 (S3 chunk 2, R1): a building look-in's own DrawCells re-enters
|
|
// with ov==0 unconditionally and neither ARMS nor CONSUMES the
|
|
// persistent PortalsDrawnCount counter — retail calls DrawCells
|
|
// re-entrantly there with no clear/seal step at all. ────────────────
|
|
|
|
[Fact]
|
|
public void LookInDrawCells_NeitherArmsNorConsumesThePortalsDrawnCounter()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint rootCellId = 0xF4180301u;
|
|
const uint lookInCellId = 0xF4180302u;
|
|
var rootCell = new WalkCell { CellId = rootCellId };
|
|
rootCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
rootCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[rootCellId] = rootCell;
|
|
var lookInCell = new WalkCell { CellId = lookInCellId };
|
|
lookInCell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
lookInCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
ctx.Cells[lookInCellId] = lookInCell;
|
|
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// Arm PortalsDrawnCount with one throwaway ov>0 interior-root flood.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var rootViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
rootViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(rootViews);
|
|
sink.OnInteriorFloodDrawTurn([rootCellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
log.Clear();
|
|
|
|
// A building look-in's own DrawCells re-enters with ov==0
|
|
// unconditionally (R1) — no LFLUSH/clear/seal step at all ("DrawCells
|
|
// re-entrantly there with no clear/seal step"), so it must leave the
|
|
// already-armed counter alone.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.OnBuildingTurn(new WalkBuilding());
|
|
sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellId]));
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.DoesNotContain("LFLUSH", log);
|
|
Assert.DoesNotContain("CLEAR", log);
|
|
Assert.DoesNotContain("SEALS", log);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// ── Deliverable: a building turn's alpha barrier precedes its portal
|
|
// pass (retail RenderDeviceD3D::DrawBuilding @0x0059f2a0:
|
|
// FlushAlphaList(0f) -> CPhysicsPart::Draw(parts,1) [the portal walk]
|
|
// -> CPhysicsPart::Draw(parts,0) [the shell] @0x0059f30b-0x0059f345);
|
|
// the punch pass runs with nothing of THIS building's own queued yet
|
|
// (the shell is not appended until the whole portal pass completes);
|
|
// the look-in DC turn draws shell-then-contents exactly like an ordinary
|
|
// interior flood; the building's own shell content is appended and
|
|
// flushed only AFTER the portal pass, at frame end; and the punch
|
|
// polygon reaches the leaf renderer transformed building-local ->
|
|
// world. ─────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void BeginEndFrame_BuildingTurnWithPunchAndLookIn_OrdersAlphaBarrierPortalPassThenShell()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong shellGfxObj = 0x0200_0010UL;
|
|
const ulong interiorGfxObj = 0x0200_0011UL;
|
|
const ulong dynamicGfxObj = 0x0200_0012UL;
|
|
InjectRenderData(fx.Manager, shellGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100010u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, interiorGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100011u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
InjectRenderData(fx.Manager, dynamicGfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100012u, TranslucencyKind.Opaque, 6, 8, 3, 3)));
|
|
|
|
var ctx = new TestContext();
|
|
var interior = new WalkCell
|
|
{
|
|
CellId = 0x104,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[interior.CellId] = interior;
|
|
|
|
var building = new WalkBuilding
|
|
{
|
|
PositionCellId = 0xA9B4000Fu,
|
|
Portals =
|
|
[
|
|
new WalkBldPortal
|
|
{
|
|
PortalSide = 0, OtherCellId = 0x104, OtherPortalId = 0,
|
|
StabList = [0x104u],
|
|
},
|
|
],
|
|
// Viewpoint (0,0,0) is on the NEGATIVE side of this splitting
|
|
// plane (d=-5): the single PORT node's side==1 arm emits its
|
|
// portal exactly once per pass (WalkBuildingPortals.Walk).
|
|
DrawingBsp = new WalkBspNode
|
|
{
|
|
SplittingPlane = new WalkPlane(new Vector3(1, 0, 0), -5f),
|
|
InPortals = [new WalkPortalRef { PortalIndex = 0, Polygon = Quad(-2f) }],
|
|
},
|
|
};
|
|
ctx.ViewerDistances[building] = 12.5f;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.ShellByBuilding[building] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(201, 0, Vector3.Zero, [new MeshRef((uint)shellGfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x104] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(202, 0, Vector3.Zero, [new MeshRef((uint)interiorGfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellDynamicsByCell[0x104] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(203, 0, Vector3.Zero, [new MeshRef((uint)dynamicGfxObj, Matrix4x4.Identity)], parentCellId: 0x104) }, 0x8C04u);
|
|
Matrix4x4 buildingWorld = Matrix4x4.CreateTranslation(10f, 0f, 0f);
|
|
worldData.WorldTransformByBuilding[building] = buildingWorld;
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var trace = new RecordingTrace(log);
|
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, leaf, worldData, trace, clipFrame);
|
|
var walk = new RetailFrameWalk();
|
|
|
|
var activeView = new WalkPortalView();
|
|
activeView.ResetForPush();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
Assert.Equal(1, activeView.ViewCount);
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
walk.DrawBuilding(building, activeView, ctx, driver);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
// Campaign OVERHAUL S2 chunk 6: a building's shell fires no
|
|
// particle turn of its own any more — interior emitters draw at
|
|
// their own cell's CellParticles turn instead.
|
|
new[]
|
|
{
|
|
"ALPHA", "PUNCH:4@v0", "SHELL:00000104",
|
|
"FLUSH:1:LookInStatic", "FLUSH:1:Dynamic",
|
|
"CELL-PARTICLES:00000104",
|
|
"FLUSH:1:BuildingShell",
|
|
},
|
|
log);
|
|
|
|
Assert.Equal([0x104u], driver.LookInCellTurns);
|
|
Assert.Equal([0x104u], driver.LookInCells);
|
|
Assert.Collection(
|
|
leaf.Shells,
|
|
shell => Assert.Equal(0x104u, shell));
|
|
WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0];
|
|
WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys);
|
|
Vector2 capturedCenter = Vector2.Zero;
|
|
for (int edge = 0; edge < capturedPoly.VertexCount; edge++)
|
|
{
|
|
capturedCenter += capturedView.View.Vertices[
|
|
capturedPoly.VertexIndex + edge].Point;
|
|
}
|
|
capturedCenter /= capturedPoly.VertexCount;
|
|
Vector3 insideCone = ctx.Rays.RayThrough(capturedCenter.X, capturedCenter.Y);
|
|
Assert.True(driver.SphereVisibleInLookInTurn(
|
|
0, in insideCone, 0.1f));
|
|
Assert.False(driver.SphereVisibleInLookInTurn(
|
|
0, new Vector3(10_000f, 0f, 10f), 0.1f));
|
|
uint clipSlot = Assert.Single(driver.VisibleClipSlotsInLookInTurn(
|
|
0, in insideCone, 0.1f, testSphere: true));
|
|
Assert.NotEqual(0u, clipSlot);
|
|
Assert.Equal(2, clipFrame.SlotCount);
|
|
|
|
// The punch polygon reached the leaf renderer in WORLD space: the
|
|
// building-local Quad(-2f) vertex (-0.5,-0.5,-2) translates by
|
|
// (10,0,0) under the caller-supplied building world transform.
|
|
WalkPolygon punch = Assert.Single(leaf.Punches);
|
|
Assert.Equal(new Vector3(9.5f, -0.5f, -2f), punch.Vertices[0]);
|
|
|
|
List<GpuRecordedMultiDrawIndirect> mdiCalls =
|
|
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
|
|
Assert.Equal(3, mdiCalls.Count);
|
|
Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount));
|
|
}
|
|
|
|
[Fact]
|
|
public void RepeatedFloodTurns_DrawEnvCellShellWholeOncePerRetailFrameStamp()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180112u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
leaf,
|
|
new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
// Neither call models a surviving exit view (ov==0 both times — no
|
|
// WalkEvent.Landscape turn ran in this synthetic double-entry
|
|
// scenario), so per S3 §8.1 R3 the device-stamp advance never fires
|
|
// from either call: this isolates the frame-stamp shell dedup from
|
|
// the landscape-flush/clear/seal gate entirely.
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal([cellId], leaf.Shells);
|
|
Assert.Equal(1, log.Count(entry => entry == "SHELL:f4180112"));
|
|
Assert.Equal(0, log.Count(entry => entry == "LFLUSH"));
|
|
Assert.Equal(0, log.Count(entry => entry == "CLEAR"));
|
|
Assert.Equal(0, log.Count(entry => entry == "SEALS"));
|
|
Assert.Equal(0, driver.PortalsDrawnCount);
|
|
}
|
|
|
|
// Campaign OVERHAUL S2 chunk 6 pin — the portal-haze bug this chunk
|
|
// fixes: a hidden/suspended owner (a portalling player materializing
|
|
// into its arrival cell) publishes NO registry rows there, so
|
|
// GetCellStatics/GetCellDynamics report empty records for that cell —
|
|
// yet retail's CPhysicsObj::add_particle_shadow_to_cell (0x00514a70)
|
|
// still draws that owner's emitter at the cell's own object-list turn,
|
|
// because particle draw membership is the emitter's OWN cell, never a
|
|
// registry/owner lookup. The walk must fire the cell's particle turn
|
|
// regardless of whether any static/dynamic record names an owner there.
|
|
[Fact]
|
|
public void EmitCellContentsTurn_FiresCellParticlesEvenWhenTheCellHasNoStaticOrDynamicRecords()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
var ctx = new TestContext();
|
|
const uint arrivalCellId = 0x8A020141u;
|
|
var arrivalCell = new WalkCell { CellId = arrivalCellId };
|
|
ctx.Cells[arrivalCellId] = arrivalCell;
|
|
|
|
// FakeWorldData has NO entry at all for the arrival cell —
|
|
// GetCellStatics/GetCellDynamics both fall back to
|
|
// WalkFrameStaticRecords.Empty, exactly the "hidden owner, no
|
|
// registry rows" scenario.
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
// No landscape turn modeled here — ov==0.
|
|
sink.OnInteriorFloodDrawTurn([arrivalCellId], outsideViewCount: 0);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Contains("CELL-PARTICLES:8a020141", log);
|
|
}
|
|
|
|
[Fact]
|
|
public void LandscapeStampBoundary_RearmsWholeShellForPostClearRootRepaint()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
const uint cellId = 0xF4180112u;
|
|
var cell = new WalkCell { CellId = cellId };
|
|
cell.PushView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
cell.TopView,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
ctx.Cells[cellId] = cell;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(new List<string>()),
|
|
new FakeWorldData());
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
|
|
// R4 (the "first-frame no-clear quirk", pinned explicitly by
|
|
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClear...):
|
|
// PortalsDrawnCount starts at 0 for a fresh driver, so ITS first
|
|
// ov>0 flood would skip the clear. Prime the counter with one
|
|
// throwaway ov>0 flood (this driver's own exit seals arm it) so the
|
|
// documented scenario below models a STEADY-STATE interior frame,
|
|
// where the clear actually fires.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var primerViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
primerViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(primerViews);
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
Assert.Equal(1, driver.PortalsDrawnCount);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
driver.RebindFrame(leaf, clipFrame: null);
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
// LScape::draw has begun. A building look-in reached this cell before
|
|
// PView::DrawCells advances m_nFrameStamp and clears interior depth.
|
|
sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
|
|
var landscapeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
landscapeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
sink.OnLandscapeViews(landscapeViews);
|
|
sink.OnBuildingTurn(new WalkBuilding());
|
|
sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [cellId]));
|
|
|
|
// The same shell must draw again after the retail stamp increment and
|
|
// full depth clear; otherwise the pre-clear color survives unpaired
|
|
// with depth and bleeds through the root's walls. ov=1 (the
|
|
// landscape turn above ran), and PortalsDrawnCount is armed from
|
|
// the primer frame's own seals, so the clear actually fires here.
|
|
sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal([cellId, cellId], leaf.Shells);
|
|
Assert.Equal(2, log.Count(entry => entry == "SHELL:f4180112"));
|
|
Assert.Contains("CLEAR", log);
|
|
Assert.True(
|
|
log.IndexOf("SHELL:f4180112") < log.IndexOf("CLEAR"),
|
|
"The look-in shell must precede the interior clear.");
|
|
Assert.True(
|
|
log.LastIndexOf("SHELL:f4180112") > log.IndexOf("SEALS"),
|
|
"The rearmed root shell must repaint after the clear and seals.");
|
|
}
|
|
|
|
// ── Fail-loud: a DrawCells turn with no preceding DrawInside/Building
|
|
// turn is a walk/driver desync, not a silent skip. ─────────────────────
|
|
|
|
[Fact]
|
|
public void Emit_DrawCellsBeforeAnyDrawInsideOrBuildingTurn_ThrowsRatherThanSilentlyDropping()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => ((IWalkEventSink)driver).Emit(WalkEvent.DrawCells(0, [0x100u])));
|
|
}
|
|
|
|
// ── Fail-loud: BeginFrame is not re-entrant. ────────────────────────────
|
|
|
|
[Fact]
|
|
public void BeginFrame_CalledWhileAFrameIsAlreadyOpen_Throws()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => driver.BeginFrame(
|
|
ctx, Matrix4x4.Identity, Vector3.Zero));
|
|
|
|
driver.EndFrame();
|
|
// EndFrame cleared the open-frame guard: BeginFrame is usable again.
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
driver.EndFrame();
|
|
}
|
|
|
|
// ── Campaign FW3.4a fail-loud: Replay without a completed Collect (no
|
|
// BeginFrame/EndFrame at all, or BeginFrame with no matching EndFrame)
|
|
// has nothing recorded to draw — throw rather than silently drawing
|
|
// nothing, which would look like an empty frame instead of a misuse. ──
|
|
|
|
[Fact]
|
|
public void Replay_WithNoPrecedingCollect_Throws()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
Assert.Throws<InvalidOperationException>(() => driver.Replay(draw.Frame, draw.Pass));
|
|
}
|
|
|
|
[Fact]
|
|
public void Replay_WhileCollectIsStillOpen_Throws()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => driver.Replay(draw.Frame, draw.Pass));
|
|
}
|
|
|
|
// ── Deliverable: Collect + Replay called as the SPLIT PAIR (never
|
|
// RunFrame) — the shape RetailPViewRenderer uses, since it must run other
|
|
// frame work (PrepareCellBatches/BuildAndBorrow) between the two. Proves
|
|
// the pair alone — with no GPU work happening until Replay — reproduces
|
|
// the same turn order RunFrame's combined call would. ──────────────────
|
|
|
|
[Fact]
|
|
public void CollectThenReplay_AsSeparateCalls_PerformsNoGpuWorkUntilReplay()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData(), new RecordingTrace(log));
|
|
var walk = new RetailFrameWalk();
|
|
// Outdoor root (camera cell low word < 0x100): a minimal, no-op
|
|
// landscape — LScape::draw still runs its sky/terrain turn against
|
|
// it even though nothing is published to walk cells/buildings for.
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.Collect(
|
|
walk, cameraCellId: 0u, cameraCell: null, landscape, ctx,
|
|
Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
// No GPU calls at all yet — Collect is CPU-only.
|
|
Assert.Empty(log);
|
|
Assert.Empty(fx.Device.Calls);
|
|
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY" }, log);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebindFrame_ReusesTheDriverAndRoutesTheNextFrameToTheNewLeaf()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var firstLog = new List<string>();
|
|
var secondLog = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(firstLog), new FakeWorldData());
|
|
var walk = new RetailFrameWalk();
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.Collect(
|
|
walk, 0u, null, landscape, ctx,
|
|
Matrix4x4.Identity, Vector3.Zero);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
driver.RebindFrame(new RecordingLeafRenderer(secondLog), clipFrame: null);
|
|
driver.Collect(
|
|
walk, 0u, null, landscape, ctx,
|
|
Matrix4x4.Identity, Vector3.Zero);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY" }, firstLog);
|
|
Assert.Equal(new[] { "SKY" }, secondLog);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebindFrame_DiscardsAnIncompletePriorFrameAndRecovers()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(new List<string>()), new FakeWorldData());
|
|
var walk = new RetailFrameWalk();
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
driver.RebindFrame(new RecordingLeafRenderer(log), clipFrame: null);
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.Collect(
|
|
walk, 0u, null, landscape, ctx,
|
|
Matrix4x4.Identity, Vector3.Zero);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY" }, log);
|
|
}
|
|
|
|
// ── Deliverable: an outdoor landscape-cell turn with no building appends
|
|
// straight to the stream (no shell call — outdoor cells have no EnvCell
|
|
// shell), and the accumulated content flushes at frame end. ───────────
|
|
|
|
[Fact]
|
|
public void OnLandscapeCellTurn_AppendsOutdoorStaticsWithNoShellCallAndFlushesAtFrameEnd()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObj = 0x0200_0020UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100020u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var ctx = new TestContext();
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[0x8C040005u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(301, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.OutdoorDynamicsByCell[0x8C040005u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(302, 0x50000001, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log));
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u);
|
|
Assert.Empty(log); // no GPU work at Collect time; nothing recorded to the log yet
|
|
driver.EndFrame();
|
|
Assert.Empty(log); // still nothing — EndFrame closes Collect, it does not Replay
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
// FW4 #132 positional invariant: the cell's emitters submit at its
|
|
// own landscape turn, after its meshes flush. Campaign OVERHAUL S2
|
|
// chunk 6: the leaf draws by CELL, not by owner set.
|
|
Assert.Equal(
|
|
new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:8c040005" },
|
|
log);
|
|
var visibleCells = new HashSet<uint> { 0xDEAD_BEEFu };
|
|
driver.CopyVisibleCellsTo(visibleCells);
|
|
Assert.Equal(new[] { 0x8C040005u }, visibleCells);
|
|
GpuRecordedMultiDrawIndirect[] mdi =
|
|
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();
|
|
Assert.Equal(2, mdi.Length);
|
|
Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount));
|
|
}
|
|
|
|
// Campaign OVERHAUL S2 chunk 6: the mesh-dedup shape (a shadow alias's
|
|
// record entering two landscape cells' dictionaries) is a MESH-only
|
|
// concern — retail's per-part render membership is whole-object per
|
|
// cell. Particles are NOT part of that alias: an emitter owns exactly
|
|
// one shadow in its own current cell (add_particle_shadow_to_cell
|
|
// 0x00514a70), so each VISITED landscape cell fires its own independent
|
|
// StaticParticles turn with no cross-cell dedupe needed or performed.
|
|
[Fact]
|
|
public void OnLandscapeCellTurn_MultiCellShadowAlias_DrawsMeshOnceButParticlesPerVisitedCell()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObj = 0x0200_0021UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100021u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var ctx = new TestContext();
|
|
var worldData = new FakeWorldData();
|
|
RenderProjectionRecord shadowAlias = MakeRecord(
|
|
303,
|
|
0x50000002,
|
|
Vector3.Zero,
|
|
[new MeshRef((uint)gfxObj, Matrix4x4.Identity)]);
|
|
WalkFrameStaticRecords aliases = new(
|
|
new[] { shadowAlias },
|
|
0xF07Fu);
|
|
worldData.OutdoorDynamicsByCell[0xF07F0040u] = aliases;
|
|
worldData.OutdoorDynamicsByCell[0xF0800001u] = aliases;
|
|
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(log),
|
|
worldData,
|
|
new RecordingTrace(log));
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xF07F0040u);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xF0800001u);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[] { "FLUSH:1:Dynamic", "PARTICLES:f07f0040", "PARTICLES:f0800001" },
|
|
log);
|
|
GpuRecordedMultiDrawIndirect[] mdi =
|
|
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();
|
|
Assert.Single(mdi);
|
|
Assert.Equal(1u, mdi[0].DrawCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void CoarseLandscapeCellTurn_ExpandsToEveryCoveredAuthoritativeOwnerCell()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0200_0022UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100022u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[0xE43D0040u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(304, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xE43Du);
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher,
|
|
new RecordingLeafRenderer(new List<string>()),
|
|
worldData);
|
|
var ctx = new TestContext();
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
// A 2x2 coarse grid's cell 3 covers authoritative x/y cells 4..7,
|
|
// including original cell 64. The old cellIndex+1 lookup only asked
|
|
// for ids 1..4 and silently lost this record.
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43DFFFFu, 2, 3);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Contains(0xE43D0040u, driver.VisitedLandscapeCellIds);
|
|
GpuRecordedMultiDrawIndirect call = Assert.Single(
|
|
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>());
|
|
Assert.Equal(1u, call.DrawCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Collect_DoesNotExposeNearAlphaToEarlierBuildingBarrier()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0200_0023UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100023u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[0xE43D0001u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(305, 0, new Vector3(0, 0, -20), [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xE43Du);
|
|
worldData.OutdoorStaticsByCell[0xE43D0002u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(306, 0, new Vector3(0, 0, -2), [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xE43Du);
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log, fx.AlphaQueue);
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
|
|
var ctx = new TestContext();
|
|
|
|
using DrawScope draw = fx.BeginDraw(beginAlpha: true);
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
var activeViews = new WalkPortalView();
|
|
WalkCopyView.AppendFullViewportQuad(
|
|
activeViews,
|
|
ctx.Rays,
|
|
ctx.WorldViewpoint,
|
|
ctx.ViewportWidth,
|
|
ctx.ViewportHeight);
|
|
((IWalkEventSink)driver).OnLandscapeViews(activeViews);
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43D0001u);
|
|
((IWalkEventSink)driver).OnBuildingTurn(new WalkBuilding());
|
|
((IWalkEventSink)driver).OnLandscapeCellTurn(0xE43D0002u);
|
|
driver.EndFrame();
|
|
|
|
Assert.Equal(0, fx.AlphaQueue.PendingCount);
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { 1 }, leaf.AlphaPendingAtBarrier);
|
|
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
|
}
|
|
|
|
// ── F4(a) (S3 chunk 3 fix round 1 §9.6): an outdoor-root sequence — the
|
|
// LandCell terrain turn precedes its own cell's object-list turn, and
|
|
// (since nothing else intervenes) the whole frame's terrain stays one
|
|
// PENDING batch until Replay's own end. Drives RetailFrameWalk.
|
|
// DrawLandscape directly with a ZERO-view WalkPortalView — the SAME
|
|
// deterministic "CY-only" admission technique RetailFrameWalkTests'
|
|
// own outdoor tests use (a real WalkFrame root's 1-view default quad
|
|
// depends on the production ray-caster's screen geometry, which this
|
|
// suite's synthetic Caster does not model faithfully enough to predict
|
|
// block/cell admission from). A side=1 block's own object-list turn
|
|
// (WalkFrameDriver.OnLandscapeCellTurn's coarse-cell expansion) fires a
|
|
// StaticParticles turn for every one of the underlying 64 owner
|
|
// buckets; marking them all "no emitters" keeps this test's sequence
|
|
// to exactly SKY + one LANDCELL, the same way a genuinely empty turn
|
|
// stays silent (F2). ────────────────────────────────────────────────
|
|
|
|
private static IEnumerable<uint> CoarseLandscapeBuckets(uint landblockPrefix)
|
|
{
|
|
for (int x = 0; x < 8; x++)
|
|
for (int y = 0; y < 8; y++)
|
|
yield return landblockPrefix | (uint)(x * 8 + y + 1);
|
|
}
|
|
|
|
/// <summary>A ONE-view <see cref="WalkPortalView"/> whose single polygon
|
|
/// has ZERO vertices — <c>WalkLandscape.CheckBlocks</c> reads only
|
|
/// <c>poly.VertexCount</c> to build its edge-plane list, so this reaches
|
|
/// the SAME permissive <c>edgeCount == 0</c> ("CY-only") admission test
|
|
/// RetailFrameWalkTests' own outdoor fixtures use via a bare
|
|
/// zero-VIEW <see cref="WalkPortalView"/> — but with <c>ViewCount == 1</c>,
|
|
/// satisfying <see cref="WalkFrameDriver"/>'s fail-loud
|
|
/// "a Landscape turn needs at least one active view" guard, which a
|
|
/// driver-level test (unlike a bare <c>IWalkEventSink</c> recorder) must
|
|
/// pass through <c>RetailFrameWalk.DrawLandscape</c>'s real
|
|
/// <c>Emit(WalkEvent.Landscape(...))</c> call.</summary>
|
|
private static WalkPortalView OneDegenerateView()
|
|
{
|
|
var view = new WalkPortalView { ViewCount = 1 };
|
|
view.View.Polys.Add(new WalkViewPoly(0, 0, 0, 0, 0, 0));
|
|
return view;
|
|
}
|
|
|
|
[Fact]
|
|
public void OutdoorRoot_LandCellPrecedesItsOwnCellsObjectTurn_ThenFlushesAtReplaysEnd()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
// Every covered bucket but ONE reports no renderable emitter, so the
|
|
// block's object turns submit nothing (no flush) except bucket
|
|
// 0xF4180001, whose particle turn is a real submission — it must land
|
|
// AFTER the coarse cell's own terrain (retail DrawLandCell → DrawSortCell).
|
|
leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
|
|
leaf.CellsWithoutEmitters.Remove(0xF4180001u);
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
|
|
var walk = new RetailFrameWalk();
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
var block = new WalkLandBlock
|
|
{
|
|
LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
|
};
|
|
block.EnsureCellArrays();
|
|
landscape.Blocks[0] = block;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
walk.DrawLandscape(landscape, OneDegenerateView(), ctx, driver);
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(new[] { "SKY", "LANDCELL:f4180000:1:0", "PARTICLES:f4180001" }, log);
|
|
Assert.Equal(new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF4180000u, 1, 0) },
|
|
Assert.Single(leaf.LandCellBatches));
|
|
}
|
|
|
|
// ── F4(b) (S3 chunk 3 fix round 1 §9.6): an interior root with one
|
|
// surviving exit view — the SAME fixture as
|
|
// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
|
|
// above, with one populated land block added to the (previously
|
|
// unpublished) landscape. Sequence: SKY, LANDCELL…, LFLUSH, SEALS,
|
|
// SHELL… — the LandCell terrain turn(s) drawn through the interior
|
|
// root's own exit view precede the landscape-flush/seal/flood-cell
|
|
// turns, exactly like the outdoor case above. ────────────────────────
|
|
|
|
[Fact]
|
|
public void InteriorRootWithExitView_DrawsLandCellThroughTheExitViewBeforeFlushSealsAndFlood()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
var log = new List<string>();
|
|
const ulong gfxObjA = 0x0200_0025UL;
|
|
const ulong gfxObjB = 0x0200_0026UL;
|
|
InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
|
|
MakeBatch(0x08100025u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
|
|
MakeBatch(0x08100026u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
|
|
|
|
var ctx = new TestContext();
|
|
var cell1 = new WalkCell
|
|
{
|
|
CellId = 0x100,
|
|
StabList = [0x101u],
|
|
Portals =
|
|
[
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
|
|
},
|
|
// The exit portal (retail's "world beyond the door") — this
|
|
// is what raises ov to 1 and drives the landscape (and now
|
|
// its own LandCell turns) before clear+seals+the flood cells.
|
|
new WalkCellPortal
|
|
{
|
|
OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1,
|
|
},
|
|
],
|
|
PortalPolygons = [Quad(-2f), Quad(-3f)],
|
|
};
|
|
var cell2 = new WalkCell
|
|
{
|
|
CellId = 0x101,
|
|
Portals = [new WalkCellPortal
|
|
{
|
|
OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
|
|
}],
|
|
PortalPolygons = [Quad(-2f)],
|
|
};
|
|
ctx.Cells[cell1.CellId] = cell1;
|
|
ctx.Cells[cell2.CellId] = cell2;
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
|
|
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
|
|
var trace = new RecordingTrace(log);
|
|
using ClipFrame clipFrame = ClipFrame.NoClip();
|
|
var driver = new WalkFrameDriver(
|
|
fx.Dispatcher, leaf, worldData, trace, clipFrame);
|
|
var walk = new RetailFrameWalk();
|
|
// The same 1x1 landscape RunFrame_InteriorFloodWithExitView... uses,
|
|
// but with a real block published in its one slot so the exit view
|
|
// has something to admit.
|
|
var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
|
|
var block = new WalkLandBlock
|
|
{
|
|
LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
|
};
|
|
block.EnsureCellArrays();
|
|
landscape.Blocks[0] = block;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.RunFrame(
|
|
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
|
|
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"SKY", "LANDCELL:f4180000:1:0", "LFLUSH", "SEALS",
|
|
"SHELL:00000101", "SHELL:00000100",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
|
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
|
},
|
|
log);
|
|
}
|
|
|
|
// ── T4 (S3 chunk 3 §9.3), re-expressed for fix round 1's F2 rule
|
|
// (§9.6 F4c): the batch is now a single PENDING list Replay keeps
|
|
// across the WHOLE frame, not a same-landblock lookahead merge — two
|
|
// LandCell turns of DIFFERENT landblocks with only an empty particle
|
|
// turn between them still merge into ONE DrawLandCellBatch call; a
|
|
// StreamMark (a real cell with statics) OR a building's own alpha
|
|
// barrier between two LandCell turns splits the batch; the fake leaf's
|
|
// HasRenderableEmittersInCell reports "no emitters" for one cell (via
|
|
// CellsWithoutEmitters) and "has emitters" (the default) for another,
|
|
// so both arms of the F2 particle-turn gate are covered in one test. ──
|
|
|
|
[Fact]
|
|
public void OnLandCellTurn_MergesAcrossLandblocksOverAnEmptyParticleTurn_RealSubmissionsSplit()
|
|
{
|
|
using var fx = new DispatcherFixture();
|
|
const ulong gfxObj = 0x0200_0024UL;
|
|
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
|
|
MakeBatch(0x08100024u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
|
|
|
|
var worldData = new FakeWorldData();
|
|
worldData.OutdoorStaticsByCell[0xBBBB0002u] = new WalkFrameStaticRecords(
|
|
new[] { MakeRecord(310, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
|
|
0xBBBBu);
|
|
|
|
var log = new List<string>();
|
|
var leaf = new RecordingLeafRenderer(log);
|
|
// The 0xAAAA0001 particle turn has no world-data records AND is
|
|
// marked without emitters -> a genuinely empty turn (F2's other
|
|
// arm: 0xBBBB0002 below keeps the default "has emitters").
|
|
leaf.CellsWithoutEmitters.Add(0xAAAA0001u);
|
|
var ctx = new TestContext();
|
|
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
|
|
IWalkEventSink sink = driver;
|
|
|
|
using DrawScope draw = fx.BeginDraw();
|
|
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
|
|
sink.OnLandscapeViews(new WalkPortalView());
|
|
|
|
sink.OnLandCellTurn(0xF4180000u, 8, 0); // landblock A
|
|
sink.OnLandscapeCellTurn(0xAAAA0001u); // empty particle turn: no emitters -> no submit, no flush
|
|
sink.OnLandCellTurn(0xF3180000u, 8, 0); // landblock B, DIFFERENT -> still merges (F2)
|
|
|
|
sink.OnLandscapeCellTurn(0xBBBB0002u); // real content -> StreamMark splits; has emitters -> submits
|
|
|
|
sink.OnLandCellTurn(0xF3180000u, 8, 1); // new batch, started after the StreamMark split
|
|
|
|
sink.OnBuildingTurn(new WalkBuilding()); // the building's own alpha barrier splits again
|
|
|
|
sink.OnLandCellTurn(0xF2180000u, 8, 0); // final batch, flushed at Replay's own end
|
|
driver.EndFrame();
|
|
driver.Replay(draw.Frame, draw.Pass);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"LANDCELL:f4180000:8:0,f3180000:8:0",
|
|
"PARTICLES:bbbb0002",
|
|
"LANDCELL:f3180000:8:1",
|
|
"ALPHA",
|
|
"LANDCELL:f2180000:8:0",
|
|
},
|
|
log);
|
|
Assert.DoesNotContain("PARTICLES:aaaa0001", log);
|
|
Assert.Equal(3, leaf.LandCellBatches.Count);
|
|
Assert.Equal(
|
|
new (uint LandblockId, int SideCellCount, int CellIndex)[]
|
|
{
|
|
(0xF4180000u, 8, 0), (0xF3180000u, 8, 0),
|
|
},
|
|
leaf.LandCellBatches[0]);
|
|
Assert.Equal(
|
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF3180000u, 8, 1) },
|
|
leaf.LandCellBatches[1]);
|
|
Assert.Equal(
|
|
new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF2180000u, 8, 0) },
|
|
leaf.LandCellBatches[2]);
|
|
}
|
|
|
|
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
|
|
// FW3.2a's own referee) ─────────────────────────────────────────────────
|
|
|
|
private static RenderProjectionRecord MakeRecord(
|
|
uint localEntityId,
|
|
uint serverGuid,
|
|
Vector3 position,
|
|
IReadOnlyList<MeshRef> meshRefs,
|
|
bool isBuildingShell = false,
|
|
uint parentCellId = 0u) =>
|
|
new(
|
|
Id: RenderProjectionId.FromRaw(localEntityId),
|
|
ProjectionClass: RenderProjectionClass.OutdoorStatic,
|
|
OwnerIncarnation: RenderOwnerIncarnation.FromRaw(1),
|
|
Transform: new RenderTransform(Matrix4x4.CreateTranslation(position)),
|
|
PreviousTransform: default,
|
|
MeshSet: default,
|
|
Material: default,
|
|
Residency: default,
|
|
Bounds: default,
|
|
Flags: RenderProjectionFlags.Draw,
|
|
DegradeState: default,
|
|
SortKey: new RenderSortKey(0),
|
|
DirtyMask: default,
|
|
Source: new RenderSourceMetadata(
|
|
LocalEntityId: localEntityId,
|
|
ServerGuid: serverGuid,
|
|
SourceId: 0,
|
|
ParentCellId: parentCellId,
|
|
EffectCellId: 0,
|
|
BuildingShellAnchorCellId: 0,
|
|
TransformFingerprint: default,
|
|
GeometryFingerprint: default,
|
|
AppearanceFingerprint: default),
|
|
EntityPayload: new RenderEntityPayload(
|
|
MeshRefs: meshRefs,
|
|
PaletteOverride: null,
|
|
IsBuildingShell: isBuildingShell));
|
|
|
|
private static ObjectRenderBatch MakeBatch(
|
|
uint surfaceId,
|
|
TranslucencyKind translucency,
|
|
uint firstIndex,
|
|
int baseVertex,
|
|
int indexCount,
|
|
uint textureSlotIndex,
|
|
uint textureLayer = 0,
|
|
CullMode cullMode = CullMode.CounterClockwise) =>
|
|
new()
|
|
{
|
|
Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false },
|
|
Translucency = translucency,
|
|
FirstIndex = firstIndex,
|
|
BaseVertex = (uint)baseVertex,
|
|
IndexCount = indexCount,
|
|
TextureSlot = new GpuTextureSlot(textureSlotIndex),
|
|
TextureIndex = (int)textureLayer,
|
|
};
|
|
|
|
private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) =>
|
|
new() { Batches = new List<ObjectRenderBatch>(batches) };
|
|
|
|
private static void InjectRenderData(ObjectMeshManager manager, ulong id, ObjectRenderData data)
|
|
{
|
|
FieldInfo field = typeof(ObjectMeshManager).GetField(
|
|
"_renderData", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException(
|
|
"ObjectMeshManager._renderData field not found — test relies on this exact name.");
|
|
var dict = (ConcurrentDictionary<ulong, ObjectRenderData>)field.GetValue(manager)!;
|
|
dict[id] = data;
|
|
}
|
|
|
|
private readonly struct DrawScope : IDisposable
|
|
{
|
|
private readonly IDisposable _publication;
|
|
private readonly IGpuPassEncoder _pass;
|
|
private readonly RetailAlphaQueue? _alpha;
|
|
|
|
public DrawScope(
|
|
IGpuFrame frame,
|
|
IGpuPassEncoder pass,
|
|
IDisposable publication,
|
|
RetailAlphaQueue? alpha = null)
|
|
{
|
|
Frame = frame;
|
|
_pass = pass;
|
|
_publication = publication;
|
|
_alpha = alpha;
|
|
}
|
|
|
|
public IGpuFrame Frame { get; }
|
|
|
|
public IGpuPassEncoder Pass => _pass;
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_alpha?.IsCollecting == true)
|
|
_alpha.EndFrame();
|
|
_publication.Dispose();
|
|
_pass.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class DispatcherFixture : IDisposable
|
|
{
|
|
private readonly WbMeshAdapter _meshAdapter;
|
|
private readonly TextureCache _textures;
|
|
|
|
public DispatcherFixture()
|
|
{
|
|
Device = new RecordingGpuDevice();
|
|
FrameLifetime = new GpuDeviceFrameLifetime(Device);
|
|
Scope = new VulkanWorldPassScope(sampleCount: 1);
|
|
_textures = new TextureCache(Device, new NoopDatReaderWriter());
|
|
_meshAdapter = new WbMeshAdapter(
|
|
Device,
|
|
new NoopDatReaderWriter(),
|
|
new NullPreparedAssetSource(),
|
|
NullLogger<WbMeshAdapter>.Instance,
|
|
Device.Retirement);
|
|
var entitySpawnAdapter = new EntitySpawnAdapter(
|
|
_textures,
|
|
_ => throw new NotSupportedException("Not exercised by these tests."));
|
|
|
|
Dispatcher = new WbDrawDispatcher(
|
|
Device,
|
|
FrameLifetime,
|
|
Scope,
|
|
_textures,
|
|
_meshAdapter,
|
|
entitySpawnAdapter,
|
|
new EntityClassificationCache(),
|
|
new AcDream.Core.Rendering.TranslucencyFadeManager(),
|
|
alphaQueue: AlphaQueue);
|
|
}
|
|
|
|
public RecordingGpuDevice Device { get; }
|
|
|
|
public GpuDeviceFrameLifetime FrameLifetime { get; }
|
|
|
|
public VulkanWorldPassScope Scope { get; }
|
|
|
|
public WbDrawDispatcher Dispatcher { get; }
|
|
|
|
public RetailAlphaQueue AlphaQueue { get; } = new();
|
|
|
|
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
|
|
|
|
public DrawScope BeginDraw(bool beginAlpha = false)
|
|
{
|
|
if (beginAlpha)
|
|
{
|
|
Dispatcher.BeginFrame(frameSlot: 0);
|
|
AlphaQueue.BeginFrame();
|
|
}
|
|
FrameLifetime.BeginFrame();
|
|
IGpuFrame frame = FrameLifetime.CurrentFrame!;
|
|
IGpuPassEncoder pass = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"fw3-2b-1-walk-frame-driver-test", Vector4.Zero, sampleCount: 1));
|
|
IDisposable publication = Scope.Publish(pass);
|
|
Device.Clear();
|
|
return new DrawScope(
|
|
frame,
|
|
pass,
|
|
publication,
|
|
beginAlpha ? AlphaQueue : null);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (AlphaQueue.IsCollecting)
|
|
AlphaQueue.AbortFrame();
|
|
Dispatcher.Dispose();
|
|
_meshAdapter.Dispose();
|
|
_textures.Dispose();
|
|
Device.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class NullPreparedAssetSource : IPreparedAssetSource
|
|
{
|
|
public PreparedAssetSourceStats Stats => default;
|
|
|
|
public CacheStats DecodedTextureCacheStats => default;
|
|
|
|
public PreparedAssetPresence Probe(
|
|
AcDream.Content.Pak.PakAssetType type,
|
|
uint sourceFileId) =>
|
|
PreparedAssetPresence.Missing;
|
|
|
|
public PreparedAssetReadResult Read(
|
|
in PreparedAssetRequest request,
|
|
CancellationToken cancellationToken = default) =>
|
|
PreparedAssetReadResult.Missing;
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class NoopDatReaderWriter : IDatReaderWriter
|
|
{
|
|
private readonly StubDatabase _portal = new();
|
|
private readonly StubDatabase _highRes = new();
|
|
private readonly StubDatabase _language = new();
|
|
private readonly StubDatabase _cell = new();
|
|
|
|
public string SourceDirectory => string.Empty;
|
|
|
|
public IDatDatabase Portal => _portal;
|
|
|
|
public IDatDatabase Cell => _cell;
|
|
|
|
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
|
new(new Dictionary<uint, IDatDatabase>());
|
|
|
|
public IDatDatabase HighRes => _highRes;
|
|
|
|
public IDatDatabase Language => _language;
|
|
|
|
public IDatDatabase Local => _language;
|
|
|
|
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
|
new(new Dictionary<uint, uint>());
|
|
|
|
public int PortalIteration => 0;
|
|
|
|
public int CellIteration => 0;
|
|
|
|
public int HighResIteration => 0;
|
|
|
|
public int LanguageIteration => 0;
|
|
|
|
public bool TryGetFileBytes(
|
|
uint regionId,
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
|
Array.Empty<IDatReaderWriter.IdResolution>();
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public bool TrySave<T>(
|
|
uint regionId,
|
|
T obj,
|
|
int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
[return: MaybeNull]
|
|
public T Get<T>(uint fileId) where T : IDBObj => default;
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class StubDatabase : IDatDatabase
|
|
{
|
|
public DatDatabase Db => throw new NotSupportedException();
|
|
|
|
public int Iteration => 0;
|
|
|
|
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
|
Array.Empty<uint>();
|
|
|
|
public bool TryGet<T>(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
[MaybeNullWhen(false)] out byte[] value)
|
|
{
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetFileBytes(
|
|
uint fileId,
|
|
ref byte[] bytes,
|
|
out int bytesRead)
|
|
{
|
|
bytesRead = 0;
|
|
return false;
|
|
}
|
|
|
|
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
|
throw new NotSupportedException();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|