Stabilize mapped SoA layout across row changes

This commit is contained in:
Meta Vens 2026-07-19 09:47:22 +01:00
commit 8c51676722
3 changed files with 32 additions and 2 deletions

View file

@ -4380,13 +4380,19 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
using var soaCache = new CultCache();
using var soaPublisher = new AetheriaRuntimeDaemonSoaFramePublisher(soaCache, producerEpoch: 1);
var soaFrame = soaPublisher.BuildCurrentZoneEntities(
using var soaFrame = soaPublisher.BuildCurrentZoneEntities(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 2, Run = run });
var mineIdentity = soaFrame.View.Identities.Single(identity =>
identity.EntityId == $"{run.RunId}:zone:0:physical-payload:{mine.PayloadId}");
Require(mineIdentity.Kind == "physical-payload" && mineIdentity.AssetRef == "prefab.entity.mine" &&
!mineIdentity.Selectable && mineIdentity.EntityIndex < -1,
"the playable SoA must carry the retained Ymir mine through its provider-owned asset identity");
var activeBodyExtent = soaFrame.View.Buffers[0].ByteLength;
var activeColumns = soaFrame.View.Columns.ToDictionary(
column => column.ColumnId,
column => (column.ByteOffset, column.ElementStride),
StringComparer.Ordinal);
soaFrame.Dispose();
var objects = AetheriaRuntimeGameDocuments.ObjectsViewport(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 2, Run = run },
@ -4411,6 +4417,18 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"Aetheria must apply authored blast damage after the delay");
Require(run.GameEvents.Count(value => value.Kind == "deployable.detonated") == 1,
"daemon must publish one authoritative detonation event");
using var settledSoaFrame = soaPublisher.BuildCurrentZoneEntities(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 3, Run = run });
Require(activeBodyExtent == settledSoaFrame.View.Buffers[0].ByteLength,
"one SoA layout version must retain a fixed body extent when active entity count changes");
foreach (var column in settledSoaFrame.View.Columns)
{
var activeColumn = activeColumns[column.ColumnId];
Require(activeColumn.ByteOffset == column.ByteOffset &&
activeColumn.ElementStride == column.ElementStride,
$"SoA column '{column.ColumnId}' must retain its byte interpretation across active row changes");
}
}
private static void DeployableRangeExpiryDetonatesAfterYmirMovement()

View file

@ -179,7 +179,10 @@ namespace GameCult.Aetheria.State.Verse
if (count > Capacity)
throw new InvalidOperationException($"Aetheria entity SoA capacity {Capacity} was exceeded by {count} rows.");
var generation = Math.Max(frame.FrameId, 0);
var layout = EntityHotSlabLayout.Create(count);
// LayoutVersion owns byte interpretation. Keep offsets and body extent fixed for the
// publisher capacity so a mapped frame may advance before reactive row metadata
// without causing the same layout version to reinterpret its bytes.
var layout = EntityHotSlabLayout.Create(Capacity);
if (!_localPublisher.TryAcquireWrite(out var write))
throw new InvalidOperationException("CultMesh has no unleased frame slot for the Aetheria SoA generation.");
try

View file

@ -174,6 +174,15 @@ radius/mass/inverse mass, render scale, visibility, LOD, and render group. This
is the right direction for a thin Unity client: daemon state appears as a native
view instead of a serialized object graph.
The publisher's `LayoutVersion` owns byte interpretation. Column offsets,
strides, and the mapped body extent are therefore derived from the fixed SoA
capacity, not the current active row count. Active element counts, identities,
and render groups remain reactive metadata. A mapped generation may advance
before that metadata notification without changing what any existing offset
means: removed rows are cleared, and newly added rows remain outside the old
element count until the matching metadata arrives. Neither EveUnity nor the
transport may repair or reinterpret a mismatched generation.
Current limitation: physics body radius, mass, inverse mass, render scale, LOD,
and render group are still generic defaults in the publisher. Entity identity
and position are real; body semantics are not yet rich enough to replace all