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.Selection; 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.2a: the walk→draw population layer's data- /// equivalence referee. Covers /// (the shared per-entity classify seam), /// (opaque → , translucent → the alpha queue, /// selection publish), and the FW3.2a own-cull-scratch fix to /// SubmitOrderedStream. /// public sealed class WalkStaticStreamPopulatorTests { // ── Test doubles ──────────────────────────────────────────────────────── private sealed class RecordingSelectionSink : IRetailSelectionRenderSink { public readonly List<(uint ServerGuid, uint LocalEntityId, int PartIndex, uint GfxObjId, Matrix4x4 LocalToWorld)> Calls = new(); public void AddVisiblePart( uint serverGuid, uint localEntityId, int partIndex, uint gfxObjId, Matrix4x4 partWorld) => Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld)); } // ── Synthetic RenderProjectionRecord construction ────────────────────── 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) }; // ── Reflection seam: ObjectMeshManager owns no test-injection API, and // driving real GPU/GfxObj upload for a unit test is out of this stage's // scope — ObjectRenderData/ObjectRenderBatch are plain settable classes, // so this seeds the manager's private cache directly. ────────────────── 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; } // ── Deliverable 1: ClassifyEntityForWalk data equivalence ───────────── [Fact] public void ClassifyEntityForWalk_OneOpaqueAndOneTranslucentPart_YieldsBatchesInRecordOrderWithCorrectIsOpaque() { using var fx = new DispatcherFixture(); const ulong opaqueGfxObj = 0x0100_0001UL; const ulong alphaGfxObj = 0x0100_0002UL; InjectRenderData(fx.Manager, opaqueGfxObj, MakeFlatMesh( MakeBatch(0x08000001u, TranslucencyKind.Opaque, firstIndex: 0, baseVertex: 0, indexCount: 3, textureSlotIndex: 1))); InjectRenderData(fx.Manager, alphaGfxObj, MakeFlatMesh( MakeBatch(0x08000002u, TranslucencyKind.AlphaBlend, firstIndex: 3, baseVertex: 4, indexCount: 6, textureSlotIndex: 2))); var meshRefs = new[] { new MeshRef((uint)opaqueGfxObj, Matrix4x4.CreateTranslation(1, 0, 0)), new MeshRef((uint)alphaGfxObj, Matrix4x4.CreateTranslation(0, 1, 0)), }; RenderProjectionRecord record = MakeRecord( localEntityId: 100, serverGuid: 0, position: new Vector3(5, 6, 7), meshRefs); var batches = new List(); var selectionParts = new List(); fx.Dispatcher.ClassifyEntityForWalk(in record, tupleLandblockId: 0x8C04u, batches, selectionParts); Assert.Equal(2, batches.Count); WbDrawDispatcher.WalkClassifiedBatch opaque = batches[0]; Assert.True(opaque.IsOpaque); Assert.Equal(TranslucencyKind.Opaque, opaque.Key.Translucency); Assert.Equal(0u, opaque.Key.FirstIndex); Assert.Equal(3, opaque.Key.IndexCount); Assert.Equal(1u, opaque.Key.TextureSlot.Index); Assert.Equal(1f, opaque.Alpha); Assert.Equal(meshRefs[0].PartTransform * record.Transform.LocalToWorld, opaque.Transform); WbDrawDispatcher.WalkClassifiedBatch translucent = batches[1]; Assert.False(translucent.IsOpaque); Assert.Equal(TranslucencyKind.AlphaBlend, translucent.Key.Translucency); Assert.Equal(3u, translucent.Key.FirstIndex); Assert.Equal(6, translucent.Key.IndexCount); Assert.Equal(2u, translucent.Key.TextureSlot.Index); Assert.Equal(meshRefs[1].PartTransform * record.Transform.LocalToWorld, translucent.Transform); Assert.Equal(2, selectionParts.Count); Assert.Equal(100u, selectionParts[0].LocalEntityId); Assert.Equal(0, selectionParts[0].PartIndex); Assert.Equal((uint)opaqueGfxObj, selectionParts[0].GfxObjId); Assert.Equal(opaque.Transform, selectionParts[0].LocalToWorld); Assert.Equal(1, selectionParts[1].PartIndex); Assert.Equal((uint)alphaGfxObj, selectionParts[1].GfxObjId); } [Fact] public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute() { using var fx = new DispatcherFixture(); const ulong setupGfxObj = 0x1000_0010UL; const ulong trunkGfxObj = 0x0100_0011UL; const ulong leavesGfxObj = 0x0100_0012UL; InjectRenderData(fx.Manager, trunkGfxObj, MakeFlatMesh( MakeBatch(0x08000011u, TranslucencyKind.Opaque, 0, 0, 3, 1))); InjectRenderData(fx.Manager, leavesGfxObj, MakeFlatMesh( MakeBatch(0x08000012u, TranslucencyKind.ClipMap, 3, 4, 6, 2))); InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData { IsSetup = true, SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)> { (trunkGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)), (leavesGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)), }, }); var meshRefs = new[] { new MeshRef((uint)setupGfxObj, Matrix4x4.Identity) }; RenderProjectionRecord record = MakeRecord(200, 0, Vector3.Zero, meshRefs); var batches = new List(); var selectionParts = new List(); fx.Dispatcher.ClassifyEntityForWalk(in record, 0x8C04u, batches, selectionParts); Assert.Equal(2, batches.Count); Assert.Equal(2, selectionParts.Count); // partIndex=0 (the entity's single top-level MeshRef) << 16 | setupPartIndex. Assert.Equal(0, selectionParts[0].PartIndex); Assert.Equal(1, selectionParts[1].PartIndex); Assert.Equal((uint)trunkGfxObj, selectionParts[0].GfxObjId); Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId); } // ── Deliverable 2: WalkStaticStreamPopulator routing ─────────────────── [Fact] public void PopulateCell_OpaqueBatchAppendsOrderedDrawCommandInRecordOrderWithStageAndCellProvenance() { using var fx = new DispatcherFixture(); const ulong gfxObj = 0x0100_0003UL; InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( MakeBatch(0x08000003u, TranslucencyKind.Opaque, 10, 2, 12, 5))); var record = MakeRecord(300, 0, new Vector3(1, 2, 3), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) }); var populator = new WalkStaticStreamPopulator(fx.Dispatcher); var stream = new OrderedDrawStream(); populator.PopulateCell( stream, WalkDrawStage.CellStatic, cellId: 0x8C040100u, new[] { record }, tupleLandblockId: 0x8C04u, cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity); Assert.Equal(1, stream.Count); Assert.Equal(WalkDrawStage.CellStatic, stream.Stages[0]); Assert.Equal(0x8C040100u, stream.CellIds[0]); Assert.Equal(10u, stream.Keys[0].FirstIndex); Assert.Equal(12, stream.Keys[0].IndexCount); Assert.Equal(1f, stream.Alphas[0]); Assert.Equal(record.Transform.LocalToWorld, stream.Transforms[0]); } [Fact] public void PopulateOutdoorStatics_UsesTheOutdoorStaticStage() { using var fx = new DispatcherFixture(); const ulong gfxObj = 0x0100_0004UL; InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( MakeBatch(0x08000004u, TranslucencyKind.Opaque, 0, 0, 3, 1))); var record = MakeRecord(400, 0, Vector3.Zero, new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) }); var populator = new WalkStaticStreamPopulator(fx.Dispatcher); var stream = new OrderedDrawStream(); populator.PopulateOutdoorStatics( stream, cellId: 0x8C040000u, new[] { record }, tupleLandblockId: 0x8C04u, cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity); Assert.Equal(1, stream.Count); Assert.Equal(WalkDrawStage.OutdoorStatic, stream.Stages[0]); } [Fact] public void PopulateCell_TranslucentBatchDoesNotAppendToTheStreamAndReachesTheAlphaQueue() { using var fx = new DispatcherFixture(withAlphaQueue: true); const ulong gfxObj = 0x0100_0005UL; InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( MakeBatch(0x08000005u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1))); var record = MakeRecord(500, 0, new Vector3(0, 0, 10), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) }); var populator = new WalkStaticStreamPopulator(fx.Dispatcher); var stream = new OrderedDrawStream(); fx.AlphaQueue!.BeginFrame(); populator.PopulateCell( stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u, cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity); Assert.Equal(0, stream.Count); Assert.Equal(1, fx.AlphaQueue.PendingCount); fx.AlphaQueue.AbortFrame(); } [Fact] public void PopulateCell_PublishesSelectionPartsForEveryClassifiedEntity() { var sink = new RecordingSelectionSink(); using var fx = new DispatcherFixture(selectionSink: sink); const ulong gfxObj = 0x0100_0006UL; InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( MakeBatch(0x08000006u, TranslucencyKind.Opaque, 0, 0, 3, 1))); var record = MakeRecord(600, serverGuid: 0x8000_0060u, new Vector3(1, 1, 1), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) }); var populator = new WalkStaticStreamPopulator(fx.Dispatcher); var stream = new OrderedDrawStream(); populator.PopulateCell( stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u, Vector3.Zero, Matrix4x4.Identity); var call = Assert.Single(sink.Calls); Assert.Equal(0x8000_0060u, call.ServerGuid); Assert.Equal(600u, call.LocalEntityId); Assert.Equal(0, call.PartIndex); Assert.Equal((uint)gfxObj, call.GfxObjId); Assert.Equal(record.Transform.LocalToWorld, call.LocalToWorld); } // ── SubmitWalkAlphaInstance: same viewer distance + per-instance data as // DeferTransparentGroups, through the REAL RetailAlphaQueue. ─────────── [Fact] public void SubmitWalkAlphaInstance_SubmitsTheSameViewerDistanceComputeViewerDistanceWouldProduce() { using var fx = new DispatcherFixture(withAlphaQueue: true); fx.AlphaQueue!.BeginFrame(); var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0); Vector3 localSortCenter = new(1, 2, 3); Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6); var cameraWorldPosition = Vector3.Zero; var batch = new WbDrawDispatcher.WalkClassifiedBatch( key, model, ClipSlot: 7, WbDrawDispatcher.InstanceLightSet.Disabled, IndoorFlag: 1, Alpha: 0.5f, SelectionLighting: new Vector2(0.25f, 0.75f), DetailCategory: 1, IsOpaque: false, LocalSortCenter: localSortCenter); fx.Dispatcher.SubmitWalkAlphaInstance(in batch, cameraWorldPosition, Matrix4x4.Identity); Assert.Equal(1, fx.AlphaQueue.PendingCount); // Same call DeferTransparentGroups makes per instance — independently // computed here so the assertion cannot pass by construction. float expectedDistance = RetailAlphaOrdering.ComputeViewerDistance( localSortCenter, model, cameraWorldPosition); FieldInfo submissionsField = typeof(RetailAlphaQueue).GetField( "_submissions", BindingFlags.NonPublic | BindingFlags.Instance)!; var submissions = (List)submissionsField.GetValue(fx.AlphaQueue)!; RetailAlphaSubmission submission = Assert.Single(submissions); Assert.Equal(expectedDistance, submission.ViewerDistance, precision: 4); Assert.Equal(0, submission.Token); fx.AlphaQueue.AbortFrame(); } [Fact] public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope() { using var fx = new DispatcherFixture(withAlphaQueue: true); fx.AlphaQueue!.BeginFrame(); var key = new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.AlphaBlend, FoliageFlags: 0); var batch = new WbDrawDispatcher.WalkClassifiedBatch( key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10)); fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Vector3.Zero, Matrix4x4.Identity); Assert.Throws(() => fx.Dispatcher.SubmitWalkAlphaInstance( in batch, Vector3.Zero, Matrix4x4.CreateTranslation(1, 0, 0))); fx.AlphaQueue.AbortFrame(); } // ── Deliverable 3: SubmitOrderedStream's own cull scratch ────────────── [Fact] public void SubmitOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch() { using var fx = new DispatcherFixture(); using DrawScope draw = fx.BeginDraw(); // Poison the SHARED _drawCullModes scratch the alpha path owns — the // exact array SubmitOrderedStream used to write into before FW3.2a. // Under the OLD shared-scratch behavior this test's second assertion // fails: SubmitOrderedStream's own command overwrites index 0 with // its own cull mode (Clockwise), destroying the alpha path's poison. FieldInfo field = typeof(WbDrawDispatcher).GetField( "_drawCullModes", BindingFlags.NonPublic | BindingFlags.Instance)!; var poisonModes = (CullMode[])field.GetValue(fx.Dispatcher)!; poisonModes[0] = CullMode.None; var stream = new OrderedDrawStream(); stream.Append(new OrderedDrawCommand( new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.Opaque, FoliageFlags: 0, CullMode: CullMode.Clockwise), Matrix4x4.Identity, WalkDrawStage.Terrain, 0, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0)); fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity); // (1) The ordered submission's OWN recorded cull call reflects the // STREAM's cull mode (Clockwise -> GpuCullMode.Front), not the // poisoned shared array's (None). List cullCalls = [.. fx.Device.Calls.OfType().Select(c => c.CullMode)]; Assert.Equal([GpuCullMode.Front], cullCalls); // (2) The shared _drawCullModes scratch is UNTOUCHED — the ordered // path never wrote through it. var afterModes = (CullMode[])field.GetValue(fx.Dispatcher)!; Assert.Equal(CullMode.None, afterModes[0]); } // ── Fixture ───────────────────────────────────────────────────────────── 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( bool withAlphaQueue = false, IRetailSelectionRenderSink? selectionSink = null) { 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.")); AlphaQueue = withAlphaQueue ? new RetailAlphaQueue() : null; Dispatcher = new WbDrawDispatcher( Device, FrameLifetime, Scope, _textures, _meshAdapter, entitySpawnAdapter, new EntityClassificationCache(), new AcDream.Core.Rendering.TranslucencyFadeManager(), selectionSink: selectionSink, alphaQueue: AlphaQueue); } public RecordingGpuDevice Device { get; } public GpuDeviceFrameLifetime FrameLifetime { get; } public VulkanWorldPassScope Scope { get; } public WbDrawDispatcher Dispatcher { get; } public RetailAlphaQueue? AlphaQueue { get; } public ObjectMeshManager Manager => _meshAdapter.MeshManager!; public DrawScope BeginDraw() { FrameLifetime.BeginFrame(); IGpuFrame frame = FrameLifetime.CurrentFrame!; IGpuPassEncoder pass = frame.BeginPass( GpuPassDescription.BackbufferClear( "fw3-2a-walk-populator-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() { } } } }