From 8c51676722d17841d2accc376fd21f3c03ebb851 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 19 Jul 2026 09:47:22 +0100 Subject: [PATCH] Stabilize mapped SoA layout across row changes --- Aetheria.State.Daemon.Smoke/Program.cs | 20 ++++++++++++++++++- .../AetheriaRuntimeDaemonSoaFramePublisher.cs | 5 ++++- docs/aetheria-current-codebase-model.md | 9 +++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index e91329703..4b1fe544a 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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() diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs index 29cc1d877..aaba0072a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs @@ -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 diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 1e165c811..01dc33798 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -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