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; /// /// Campaign FW stage FW3.2b-1: 's headless /// referee suite — proves that driving with the /// driver as its produces retail's own turn /// order (shell-then-contents per cell, flush-before-every-leaf-action, /// content-before-punch) through the REAL /// onto a — never a mock of the submission /// path itself. No production wiring is exercised (WorldSceneRenderer /// still does not construct this driver); every world-data/leaf-renderer /// dependency here is a synthetic fake per plan §FW3.2b-1. /// 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 log) : IWalkFrameLeafRenderer { public readonly List Punches = new(); public void DrawSky() => log.Add("SKY"); public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}"); public void DrawCellShell(uint cellId) => log.Add($"SHELL:{cellId:x8}"); public void DrawPunchFan(WalkPolygon worldPolygon) { Punches.Add(worldPolygon); log.Add($"PUNCH:{worldPolygon.Vertices.Length}"); } public void AlphaBarrier(float viewerDistance) => log.Add($"ALPHA:{viewerDistance:F2}"); } private sealed class RecordingTrace(List log) : IWalkFrameDriverTrace { public void OnFlush(int commandCount, IReadOnlyList stages) => log.Add($"FLUSH:{commandCount}:{string.Join(',', stages.Distinct())}"); } private sealed class FakeWorldData : IWalkFrameWorldData { public readonly Dictionary CellStaticsByCell = new(); public readonly Dictionary OutdoorStaticsByCell = new(); public readonly Dictionary ShellByBuilding = new(); public readonly Dictionary WorldTransformByBuilding = new(); public WalkFrameStaticRecords GetCellStatics(uint cellId) => CellStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => OutdoorStaticsByCell.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 Cells = new(); public readonly Dictionary 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 output) { Span 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; shell // precedes contents per cell, and a flush happens exactly at the point // the NEXT cell's shell needs the stream clear (never before, never // batched across cells within this stage's turn-by-turn discipline). ── [Fact] public void RunFrame_InteriorTwoCellFlood_EmitsShellThenContentsPerCellWithAFlushBetween() { using var fx = new DispatcherFixture(); var log = new List(); 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, }], 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( [MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)])], 0x8C04u); worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords( [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, activeTerrainSliceCount: 0); Assert.Equal( new[] { "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic" }, log); List mdiCalls = [.. fx.Device.Calls.OfType()]; 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)); } // ── 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(); const ulong shellGfxObj = 0x0200_0010UL; const ulong interiorGfxObj = 0x0200_0011UL; 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))); 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( [MakeRecord(201, 0, Vector3.Zero, [new MeshRef((uint)shellGfxObj, Matrix4x4.Identity)])], 0x8C04u); worldData.CellStaticsByCell[0x104] = new WalkFrameStaticRecords( [MakeRecord(202, 0, Vector3.Zero, [new MeshRef((uint)interiorGfxObj, Matrix4x4.Identity)])], 0x8C04u); Matrix4x4 buildingWorld = Matrix4x4.CreateTranslation(10f, 0f, 0f); worldData.WorldTransformByBuilding[building] = buildingWorld; var leaf = new RecordingLeafRenderer(log); var trace = new RecordingTrace(log); var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace); 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, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); walk.DrawBuilding(building, activeView, ctx, driver); driver.EndFrame(); Assert.Equal( new[] { "ALPHA:12.50", "PUNCH:4", "SHELL:00000104", "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell" }, log); // 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 mdiCalls = [.. fx.Device.Calls.OfType()]; Assert.Equal(2, mdiCalls.Count); Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount)); } // ── 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(); var ctx = new TestContext(); var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData()); using DrawScope draw = fx.BeginDraw(); driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); Assert.Throws( () => ((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(); var ctx = new TestContext(); var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData()); using DrawScope draw = fx.BeginDraw(); driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); Assert.Throws( () => driver.BeginFrame( ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0)); driver.EndFrame(); // EndFrame cleared the open-frame guard: BeginFrame is usable again. driver.BeginFrame(ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); driver.EndFrame(); } // ── 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(); 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( [MakeRecord(301, 0, 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, draw.Frame, draw.Pass, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0); ((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u); Assert.Empty(log); // accumulates in the stream; nothing flushed yet driver.EndFrame(); Assert.Equal(new[] { "FLUSH:1:OutdoorStatic" }, log); GpuRecordedMultiDrawIndirect mdi = Assert.Single(fx.Device.Calls.OfType()); Assert.Equal(1u, mdi.DrawCount); } // ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture — // FW3.2a's own referee) ───────────────────────────────────────────────── private static RenderProjectionRecord MakeRecord( uint localEntityId, uint serverGuid, Vector3 position, IReadOnlyList 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(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)field.GetValue(manager)!; dict[id] = data; } private readonly struct DrawScope : IDisposable { private readonly IDisposable _publication; private readonly IGpuPassEncoder _pass; public DrawScope(IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication) { Frame = frame; _pass = pass; _publication = publication; } public IGpuFrame Frame { get; } public IGpuPassEncoder Pass => _pass; public void Dispose() { _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.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()); } public RecordingGpuDevice Device { get; } public GpuDeviceFrameLifetime FrameLifetime { get; } public VulkanWorldPassScope Scope { get; } public WbDrawDispatcher Dispatcher { get; } public ObjectMeshManager Manager => _meshAdapter.MeshManager!; public DrawScope BeginDraw() { 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); } public void Dispose() { 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 CellRegions { get; } = new(new Dictionary()); public IDatDatabase HighRes => _highRes; public IDatDatabase Language => _language; public IDatDatabase Local => _language; public ReadOnlyDictionary RegionFileMap { get; } = new(new Dictionary()); 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 GetAllIdsOfType() where T : IDBObj => Array.Empty(); public IEnumerable ResolveId(uint id) => Array.Empty(); public bool TrySave(T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException(); public bool TrySave( uint regionId, T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException(); [return: MaybeNull] public T Get(uint fileId) where T : IDBObj => default; public bool TryGet( 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 GetAllIdsOfType() where T : IDBObj => Array.Empty(); public bool TryGet( 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 obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException(); public void Dispose() { } } } }