refactor(render): move look-in dynamics onto walk turns

This commit is contained in:
Erik 2026-08-31 03:39:03 +02:00
parent 966836895f
commit f9a80fbc44
9 changed files with 190 additions and 113 deletions

View file

@ -204,9 +204,6 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
private readonly Action _drawExitSeals;
private readonly HashSet<uint> _singleCellScratch = new();
private readonly List<uint> _singleCellListScratch = new();
private readonly HashSet<uint> _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));
/// <summary>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.</summary>
internal void BindEntityFrame(in RenderFrameView view)
public void DrawCellParticles(uint cellId, IReadOnlySet<uint> ownerIds)
{
_entityFrame = view;
_entityFrameBound = true;
}
public void DrawLookInDynamics(
uint cellId,
int routeIndex,
IReadOnlySet<uint> 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();

View file

@ -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.

View file

@ -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();

View file

@ -46,6 +46,12 @@ internal interface IWalkFrameWorldData
/// look-in's) static content — <c>RenderProjectionClass.IndoorCellStatic</c>.</summary>
WalkFrameStaticRecords GetCellStatics(uint cellId);
/// <summary>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 <c>viewconeCheck</c>.</summary>
WalkFrameStaticRecords GetCellDynamics(uint cellId);
/// <summary>One visited landscape (outdoor) cell's static content —
/// <c>RenderProjectionClass.OutdoorStatic</c>, keyed by the SAME
/// <c>(landblockId &amp; 0xFFFF0000) | (cellIndex+1)</c> id
@ -117,16 +123,10 @@ internal interface IWalkFrameLeafRenderer
/// positional invariant this carries (the #132 falls containment).</summary>
void DrawStaticParticles(IReadOnlySet<uint> ownerIds);
/// <summary>Draws the packed dynamic occupants of one building look-in
/// cell at that cell's OWN <c>PView::DrawCells</c> 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
/// <c>LookInObject</c> ranges.</summary>
void DrawLookInDynamics(
uint cellId,
int routeIndex,
IReadOnlySet<uint> staticParticleOwnerIds);
/// <summary>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.</summary>
void DrawCellParticles(uint cellId, IReadOnlySet<uint> ownerIds);
/// <summary><c>PView::DrawCells</c> @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).</summary>
StaticParticles,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawLookInDynamics"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the look-in cell and
/// <see cref="WalkFrameEvent.IntArg"/> is its walk-ordered packed route
/// index.</summary>
LookInDynamics,
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellParticles"/> —
/// <see cref="WalkFrameEvent.CellId"/> is the cell.</summary>
CellParticles,
}
/// <summary>
@ -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));
}
}

View file

@ -61,12 +61,14 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private int _renderCenterLbY;
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _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))

View file

@ -97,6 +97,33 @@ internal sealed class WalkStaticStreamPopulator
stream, WalkDrawStage.OutdoorStatic, cellId, records,
tupleLandblockId, cameraWorldPosition, viewProjection);
internal void PopulateCellDynamics(
OrderedDrawStream stream,
uint cellId,
ReadOnlySpan<RenderProjectionRecord> 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++)
{

View file

@ -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<WalkClassifiedBatch> batches,
List<WalkClassifiedSelectionPart> selectionParts)
List<WalkClassifiedSelectionPart> 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<WalkClassifiedBatch> 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));
}

View file

@ -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,

View file

@ -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<uint> staticParticleOwnerIds)
{
var sorted = new List<uint>(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<uint, WalkFrameStaticRecords> CellStaticsByCell = new();
public readonly Dictionary<uint, WalkFrameStaticRecords> CellDynamicsByCell = new();
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorStaticsByCell = new();
public readonly Dictionary<WalkBuilding, WalkFrameStaticRecords> ShellByBuilding = new();
public readonly Dictionary<WalkBuilding, Matrix4x4> 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<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
@ -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<GpuRecordedMultiDrawIndirect> mdiCalls =
[.. fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()];
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