diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index 385c0906..e54a014b 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -204,9 +204,6 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer private readonly Action _drawExitSeals; private readonly HashSet _singleCellScratch = new(); private readonly List _singleCellListScratch = new(); - private readonly HashSet _lookInParticleOwnerScratch = new(); - private RenderFrameView _entityFrame; - private bool _entityFrameBound; internal WalkProductionLeafRenderer( RetailPViewPassExecutor passes, @@ -248,53 +245,11 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer _passes.DrawLandscapeStaticParticles( _frame, new RetailPViewLandscapeStaticParticleContext(ownerIds)); - /// Binds the packed frame product built after the walk's Collect - /// pass and before Replay. The walk records route indices during Collect; - /// Replay consumes the matching product ranges through this exact view. - internal void BindEntityFrame(in RenderFrameView view) + public void DrawCellParticles(uint cellId, IReadOnlySet ownerIds) { - _entityFrame = view; - _entityFrameBound = true; - } - - public void DrawLookInDynamics( - uint cellId, - int routeIndex, - IReadOnlySet staticParticleOwnerIds) - { - if (!_entityFrameBound) - { - throw new InvalidOperationException( - "Walk look-in dynamics replayed before the packed entity frame was bound."); - } - - _passes.UseIndoorMembershipOnlyRouting(); - _passes.DrawEntityRoute( - _frame.Camera, - in _entityFrame, - RenderFrameCandidateRoute.LookInObject, - routeIndex, - cellId, - _frame.PlayerLandblockId ?? 0u); - - _lookInParticleOwnerScratch.Clear(); - _lookInParticleOwnerScratch.UnionWith(staticParticleOwnerIds); - RenderFrameRouteOwnerSelector.Union( - _lookInParticleOwnerScratch, - in _entityFrame, - RenderFrameCandidateRoute.LookInObject, - routeIndex, - cellId); - if (_lookInParticleOwnerScratch.Count > 0) - { - _passes.DrawCellParticles( - _frame, - new RetailPViewCellSliceContext( - cellId, - default, - _lookInParticleOwnerScratch)); - } - + _passes.DrawCellParticles( + _frame, + new RetailPViewCellSliceContext(cellId, default, ownerIds)); } public void DrawExitSeals() => _drawExitSeals(); diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index e43d83b2..33b01447 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -550,8 +550,6 @@ public sealed class RetailPViewRenderer frameViewBorrowed = true; frameEntityPasses!.BeginEntityFrame(in frameView); entityFrameOpen = true; - if (walkActive) - walkLeafRenderer!.BindEntityFrame(in frameView); } // The retained scene product is the production object source. diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 64999f3d..5937515e 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1207,16 +1207,10 @@ internal sealed class RenderScenePViewFrameBuilder for (int index = 0; index < input.LookInCellTurns.Count; index++) _lookInCellScratch.Add(input.LookInCellTurns[index]); - // Campaign FW3.2b-2: LandscapeOutdoorStatic, LandscapeBuildingShell, - // and CellStatic no longer emit here — WalkFrameDriver draws every - // outdoor static, building shell, and cell static (including - // look-in cell statics) directly through OrderedDrawStream (plan - // §FW3 "FW3.2b-2 — the production rooting", item 3). LookInObject - // keeps the packed classifier for live animation/fades, but its - // route ranges are keyed to THE WALK'S own look-in cell turns. - // The legacy PortalVisibilityFrame list no longer has a production - // admission or ordering role. - BuildLookInRoutes(writer, in input); + // FW4: the walk now owns the complete look-in object-list turn — + // statics, live dynamics, fades, picking, and particles. Do not + // advertise a packed LookInObject range the dispatcher cannot and + // must not consume a second time. BuildOutsideDynamicRoutes(writer, in input); BuildDynamicLastRoute(writer, in input); writer.Publish(); diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 8c45fb17..6b08ee1b 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -46,6 +46,12 @@ internal interface IWalkFrameWorldData /// look-in's) static content — RenderProjectionClass.IndoorCellStatic. WalkFrameStaticRecords GetCellStatics(uint cellId); + /// The live dynamic occupants of one indoor cell. These records + /// enter the ordered stream at that cell's own object-list turn; a + /// building look-in supplies its exact installed portal view for the + /// per-part viewconeCheck. + WalkFrameStaticRecords GetCellDynamics(uint cellId); + /// One visited landscape (outdoor) cell's static content — /// RenderProjectionClass.OutdoorStatic, keyed by the SAME /// (landblockId & 0xFFFF0000) | (cellIndex+1) id @@ -117,16 +123,10 @@ internal interface IWalkFrameLeafRenderer /// positional invariant this carries (the #132 falls containment). void DrawStaticParticles(IReadOnlySet ownerIds); - /// Draws the packed dynamic occupants of one building look-in - /// cell at that cell's OWN PView::DrawCells turn, then submits the - /// cell's static + dynamic particle owners at the same turn. The route - /// index is assigned in the exact order Collect encountered look-in cell - /// turns and therefore matches the frame product's walk-keyed - /// LookInObject ranges. - void DrawLookInDynamics( - uint cellId, - int routeIndex, - IReadOnlySet staticParticleOwnerIds); + /// Submits one indoor cell's static + dynamic particle owners at + /// that cell's own object-list turn. Meshes have already entered the + /// ordered stream before this leaf event. + void DrawCellParticles(uint cellId, IReadOnlySet ownerIds); /// PView::DrawCells @0x005a4840's gated full depth clear /// (pc:432731-432732) between the outside stage and the interior root's @@ -270,11 +270,9 @@ internal enum WalkFrameEventKind : byte /// `e102fb36` encoded the same invariant). StaticParticles, - /// — - /// is the look-in cell and - /// is its walk-ordered packed route - /// index. - LookInDynamics, + /// — + /// is the cell. + CellParticles, } /// @@ -359,8 +357,8 @@ internal readonly struct WalkFrameEvent internal static WalkFrameEvent BuildingShellParticles(WalkBuilding building) => new(WalkFrameEventKind.StaticParticles, 0, 0, 0f, null, building); - internal static WalkFrameEvent LookInDynamics(uint cellId, int routeIndex) => - new(WalkFrameEventKind.LookInDynamics, routeIndex, cellId, 0f, null); + internal static WalkFrameEvent CellParticles(uint cellId) => + new(WalkFrameEventKind.CellParticles, 0, cellId, 0f, null); internal static WalkFrameEvent ClearInteriorDepth() => new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null); @@ -724,15 +722,20 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource if (_staticParticleOwnerScratch.Count > 0) _leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch); break; - case WalkFrameEventKind.LookInDynamics: + case WalkFrameEventKind.CellParticles: _staticParticleOwnerScratch.Clear(); UnionOwners( _worldData.GetCellStatics(e.CellId), _staticParticleOwnerScratch); - _leafRenderer.DrawLookInDynamics( - e.CellId, - e.IntArg, + UnionOwners( + _worldData.GetCellDynamics(e.CellId), _staticParticleOwnerScratch); + if (_staticParticleOwnerScratch.Count > 0) + { + _leafRenderer.DrawCellParticles( + e.CellId, + _staticParticleOwnerScratch); + } break; } } @@ -990,7 +993,20 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource LookInCellTurns.Add(cellId); LookInCells.Add(cellId); CaptureLookInViews(cellId); - _events.Add(WalkFrameEvent.LookInDynamics(cellId, routeIndex)); + + WalkFrameStaticRecords dynamics = _worldData.GetCellDynamics(cellId); + _populator.PopulateCellDynamics( + _stream, + cellId, + dynamics.Records, + dynamics.TupleLandblockId, + _cameraWorldPosition, + _viewProjection, + this, + routeIndex); + MarkIfGrown(); + if (HasAnyOwner(records) || HasAnyOwner(dynamics)) + _events.Add(WalkFrameEvent.CellParticles(cellId)); } } diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs index 0685f1be..85de51ed 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -61,12 +61,14 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData private int _renderCenterLbY; private readonly Dictionary _cellCache = new(); + private readonly Dictionary _cellDynamicCache = new(); private readonly Dictionary> _outdoorByCell = new(); private readonly Dictionary> _shellsByAnchor = new(); private readonly Dictionary _outdoorMaterialized = new(); private readonly Dictionary _shellMaterialized = new(); private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256]; + private RenderProjectionRecord[] _cellDynamicScratch = new RenderProjectionRecord[256]; // Campaign FW3.4a: the per-frame, grow-only materialization arena — see // this type's own doc comment. @@ -98,6 +100,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData _renderCenterLbX = renderCenterLbX; _renderCenterLbY = renderCenterLbY; _cellCache.Clear(); + _cellDynamicCache.Clear(); _outdoorMaterialized.Clear(); _shellMaterialized.Clear(); _arenaLength = 0; @@ -180,6 +183,25 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData return records; } + public WalkFrameStaticRecords GetCellDynamics(uint cellId) + { + if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) + return cached; + int required = _scene.GetCellDynamicCount(cellId); + if (required > _cellDynamicScratch.Length) + { + _cellDynamicScratch = new RenderProjectionRecord[ + Math.Max(required, _cellDynamicScratch.Length * 2)]; + } + int count = _scene.CopyCellDynamicsTo(cellId, _cellDynamicScratch); + WalkFrameStaticRecords records = count == 0 + ? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId } + : new WalkFrameStaticRecords( + AppendToArena(_cellDynamicScratch.AsSpan(0, count)), _tupleLandblockId); + _cellDynamicCache[cellId] = records; + return records; + } + public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) { if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached)) diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index d138c2cf..d4079364 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -97,6 +97,33 @@ internal sealed class WalkStaticStreamPopulator stream, WalkDrawStage.OutdoorStatic, cellId, records, tupleLandblockId, cameraWorldPosition, viewProjection); + internal void PopulateCellDynamics( + OrderedDrawStream stream, + uint cellId, + ReadOnlySpan records, + uint tupleLandblockId, + Vector3 cameraWorldPosition, + Matrix4x4 viewProjection, + IWalkLookInViewSource? lookInViews = null, + int lookInRouteIndex = -1) + { + ArgumentNullException.ThrowIfNull(stream); + for (int i = 0; i < records.Length; i++) + { + ClassifyAndAppend( + stream, + WalkDrawStage.Dynamic, + cellId, + in records[i], + tupleLandblockId, + cameraWorldPosition, + viewProjection, + liveDynamic: true, + lookInViews, + lookInRouteIndex); + } + } + private void ClassifyAndAppend( OrderedDrawStream stream, WalkDrawStage stage, @@ -104,12 +131,22 @@ internal sealed class WalkStaticStreamPopulator in RenderProjectionRecord record, uint tupleLandblockId, Vector3 cameraWorldPosition, - Matrix4x4 viewProjection) + Matrix4x4 viewProjection, + bool liveDynamic = false, + IWalkLookInViewSource? lookInViews = null, + int lookInRouteIndex = -1) { _batchScratch.Clear(); _selectionScratch.Clear(); _dispatcher.ClassifyEntityForWalk( - in record, tupleLandblockId, _batchScratch, _selectionScratch); + in record, + tupleLandblockId, + _batchScratch, + _selectionScratch, + liveDynamic, + lookInViews, + lookInRouteIndex, + cellId); for (int i = 0; i < _batchScratch.Count; i++) { diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index 1e1351b2..80e3674f 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Selection; +using AcDream.App.Rendering.Walk; using AcDream.Core.Meshing; using AcDream.Core.World; using DatReaderWriter.Enums; @@ -182,13 +183,20 @@ public sealed partial class WbDrawDispatcher in RenderProjectionRecord projection, uint tupleLandblockId, List batches, - List selectionParts) + List selectionParts, + bool liveDynamic = false, + IWalkLookInViewSource? lookInViews = null, + int lookInRouteIndex = -1, + uint lookInCellId = 0) { ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(selectionParts); RenderInstanceCandidate entity = - RenderInstanceCandidate.FromProjection(in projection, tupleLandblockId); + RenderInstanceCandidate.FromProjection( + in projection, + tupleLandblockId, + animated: liveDynamic); (uint slot, bool culled) = ResolveSlotForFrame( _clipRoutingActive, entity.ServerGuid, entity.ParentCell, @@ -217,7 +225,11 @@ public sealed partial class WbDrawDispatcher MeshRef meshRef = meshRefs[partIndex]; ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId); if (renderData is null) + { + if (_missRequested.Add(meshRef.GfxObjId)) + _meshAdapter.EnsureLoaded(meshRef.GfxObjId); continue; + } if (renderData.IsSetup && renderData.SetupParts.Count > 0) { @@ -236,16 +248,42 @@ public sealed partial class WbDrawDispatcher (ulong gfxObjId, Matrix4x4 partTransform) = renderData.SetupParts[setupPartIndex]; ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId); if (partData is null) + { + if (_missRequested.Add(gfxObjId)) + _meshAdapter.EnsureLoaded(gfxObjId); + continue; + } + + float opacity = liveDynamic + ? PackedPartOpacity( + entity.ServerGuid, + entity.LocalEntityId, + (uint)setupPartIndex) + : 1f; + if (opacity <= 0f) continue; Matrix4x4 restPose = partTransform * meshRef.PartTransform; Matrix4x4 model = restPose * entity.RootWorld; int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF)); + if (!PartVisibleInLookInTurn( + lookInViews, + lookInRouteIndex, + lookInCellId, + in entity, + selectionPartIndex, + (uint)gfxObjId, + partData, + model)) + { + continue; + } + EmitClassifiedBatches( partData, model, in entity, meshRef, paletteIdentity, entityHasCutoutSubset, slot, lights, indoor, selectionLighting, - detailCategory, batches); + detailCategory, opacity, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, selectionPartIndex, (uint)gfxObjId, model)); @@ -253,11 +291,32 @@ public sealed partial class WbDrawDispatcher } else { + float opacity = liveDynamic + ? PackedPartOpacity( + entity.ServerGuid, + entity.LocalEntityId, + (uint)partIndex) + : 1f; + if (opacity <= 0f) + continue; + Matrix4x4 model = meshRef.PartTransform * entity.RootWorld; + if (!PartVisibleInLookInTurn( + lookInViews, + lookInRouteIndex, + lookInCellId, + in entity, + partIndex, + (uint)meshRef.GfxObjId, + renderData, + model)) + { + continue; + } EmitClassifiedBatches( renderData, model, in entity, meshRef, paletteIdentity, entityHasCutoutSubsetOverride: null, slot, lights, indoor, - selectionLighting, detailCategory, batches); + selectionLighting, detailCategory, opacity, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, partIndex, (uint)meshRef.GfxObjId, model)); @@ -283,6 +342,7 @@ public sealed partial class WbDrawDispatcher bool indoor, Vector2 selectionLighting, uint detailCategory, + float opacity, List sink) { bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset; @@ -290,13 +350,13 @@ public sealed partial class WbDrawDispatcher { bool survives = TryClassifyBatch( renderData, batchIdx, in entity, meshRef, paletteIdentity, - opacityMultiplier: 1.0f, entityHasCutoutSubset, + opacityMultiplier: opacity, entityHasCutoutSubset, out GroupKey key, out _); if (!survives) continue; sink.Add(new WalkClassifiedBatch( - key, model, slot, lights, indoor ? 1u : 0u, Alpha: 1f, + key, model, slot, lights, indoor ? 1u : 0u, Alpha: opacity, selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency), LocalSortCenter: renderData.SortCenter)); } diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index 4cf9975e..03780c03 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -227,17 +227,9 @@ public sealed class RenderScenePViewFrameProductTests RenderFrameView frame = exchange.BorrowLatest(Generation, 1); try { - RenderFrameCandidateRange lookInRange = Assert.Single( + Assert.DoesNotContain( frame.RouteRanges.ToArray(), range => range.Route == RenderFrameCandidateRoute.LookInObject); - Assert.Equal(0, lookInRange.RouteIndex); - Assert.Equal(lookInCell, lookInRange.CellId); - Assert.Equal( - [lookInDynamic.Id], - frame.RouteCandidates - .Slice(lookInRange.Offset, lookInRange.Count) - .ToArray() - .Select(static record => record.Id)); RenderFrameCandidateRange outside = Assert.Single( frame.RouteRanges.ToArray(), @@ -303,15 +295,9 @@ public sealed class RenderScenePViewFrameProductTests RenderFrameView frame = exchange.BorrowLatest(Generation, 1); try { - RenderFrameCandidateRange range = Assert.Single( + Assert.DoesNotContain( frame.RouteRanges.ToArray(), candidate => candidate.Route == RenderFrameCandidateRoute.LookInObject); - Assert.Equal( - [lookInDynamic.Id], - frame.RouteCandidates - .Slice(range.Offset, range.Count) - .ToArray() - .Select(static record => record.Id)); Assert.NotNull(frame.WalkLookInViews); Assert.False(frame.WalkLookInViews!.SphereVisibleInLookInTurn( routeIndex: 0, diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 9e5210f1..0e11944e 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -69,15 +69,14 @@ public sealed class WalkFrameDriverTests log.Add($"PARTICLES:{string.Join(",", sorted.ConvertAll(o => o.ToString("x")))}"); } - public void DrawLookInDynamics( + public void DrawCellParticles( uint cellId, - int routeIndex, IReadOnlySet staticParticleOwnerIds) { var sorted = new List(staticParticleOwnerIds); sorted.Sort(); log.Add( - $"LOOKIN:{cellId:x8}:{routeIndex}:" + $"CELL-PARTICLES:{cellId:x8}:" + string.Join(",", sorted.ConvertAll(o => o.ToString("x")))); } } @@ -91,6 +90,7 @@ public sealed class WalkFrameDriverTests private sealed class FakeWorldData : IWalkFrameWorldData { public readonly Dictionary CellStaticsByCell = new(); + public readonly Dictionary CellDynamicsByCell = new(); public readonly Dictionary OutdoorStaticsByCell = new(); public readonly Dictionary ShellByBuilding = new(); public readonly Dictionary WorldTransformByBuilding = new(); @@ -98,6 +98,9 @@ public sealed class WalkFrameDriverTests 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); @@ -364,10 +367,13 @@ public sealed class WalkFrameDriverTests var log = new List(); 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 @@ -408,6 +414,8 @@ public sealed class WalkFrameDriverTests 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; @@ -434,7 +442,8 @@ public sealed class WalkFrameDriverTests new[] { "ALPHA:12.50", "PUNCH:4@v0", "SHELL:00000104", - "FLUSH:1:LookInStatic", "LOOKIN:00000104:0:ca", + "FLUSH:1:LookInStatic", "FLUSH:1:Dynamic", + "CELL-PARTICLES:00000104:ca,cb", "FLUSH:1:BuildingShell", "PARTICLES:c9", }, log); @@ -464,8 +473,8 @@ public sealed class WalkFrameDriverTests List mdiCalls = [.. fx.Device.Calls.OfType()]; - Assert.Equal(2, mdiCalls.Count); - Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount)); + Assert.Equal(3, mdiCalls.Count); + Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount)); } // ── Fail-loud: a DrawCells turn with no preceding DrawInside/Building