diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index 0b7893ce..0593b2a8 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -18,8 +18,9 @@ retail-faithful gameplay |---|---|---| | F0a — partition-input referee | complete | Diagnostic-only `CurrentRenderSceneOracle`; no production draw decision changed. | | F0b — survivor/dispatcher/selection referee | complete | Exact PView routes, dispatcher candidates and final group payloads, material/alpha/clip/light/selection fields, and accepted picking parts. Release gate: 3,690 App tests / 3 skips and 8,174 complete-solution tests / 5 skips. | -| F1 — scene types and contained adapter | active | No render-scene storage or shadow projection exists yet. | -| F2–F5 | pending | Delta publication and continuous shadow comparison have not started. | +| F1 — scene types and contained adapter | complete | `Arch 2.1.0` is pinned in App only behind acdream contracts. Five narrow archetypes, generation/incarnation/sequence gates, borrowed-query invalidation, memory accounting, deterministic digesting, and update-thread enforcement pass 11 focused tests. Release gate: 3,701 App tests / 3 skips and 8,185 complete-solution tests / 5 skips. | +| F2 — static projection journal | active | Static publication/retirement owners do not emit render deltas yet. | +| F3–F5 | pending | Live/equipped publication, dynamic indices, and continuous shadow comparison have not started. | | G0–G5 | pending | No production consumer has switched. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is @@ -314,6 +315,17 @@ performance regression with comparison disabled. **Commit gate:** the scene is unbound and non-drawing; full suite green. +**Landed evidence:** the package/API/license decision is recorded in +`docs/research/2026-07-24-arch-render-scene-dependency.md`. Production +composition does not construct `ArchRenderScene`; F1 therefore changes no +runtime path. Source/reflection architecture tests confine all Arch types and +primitive-ID extraction to `AcDream.App/Rendering/Scene/Arch`. Pure App tests +cover every structural/channel operation, generation replacement, stale +incarnation and sequence rejection, borrowed-query invalidation, deterministic +digest order, retained-memory accounting, and cross-thread mutation rejection. +The Release gate is 3,701 App tests / 3 skips and 8,185 complete-solution tests +/ 5 skips. + ### F2 — Static projection journal 1. Append Register deltas from the existing final landblock spatial publication diff --git a/docs/research/2026-07-24-arch-render-scene-dependency.md b/docs/research/2026-07-24-arch-render-scene-dependency.md new file mode 100644 index 00000000..b6ec6abf --- /dev/null +++ b/docs/research/2026-07-24-arch-render-scene-dependency.md @@ -0,0 +1,77 @@ +# Arch render-scene dependency decision + +**Date:** 2026-07-24 + +**Scope:** Modern Runtime Slice F1 only + +## Decision + +Pin `Arch` version `2.1.0` directly in `AcDream.App`. Do not reference it from +Core, UI abstractions, plugins, or any other production project. Do not use +Arch systems, command buffers, parallel queries, jobs, events, source +generators, persistence, relationships, or extension packages. + +The only allowed Arch namespace is: + +```text +src/AcDream.App/Rendering/Scene/Arch/ +``` + +Everything outside that folder uses acdream-owned `IRenderScene`, wrapper +identities, packed component values, deltas, borrowed queries, digests, and +memory-accounting values. Arch entities and primitive wrapper values cannot +cross that boundary. + +## Verification + +- Official package: [NuGet `Arch 2.1.0`](https://www.nuget.org/packages/Arch/2.1.0) +- Official source/tag: + [genaray/Arch `v2.1.0`](https://github.com/genaray/Arch/tree/v2.1.0) +- License: + [Apache License 2.0](https://github.com/genaray/Arch/blob/v2.1.0/LICENSE.MD) +- Selected source tag commit: + `04d52e7268eb6f376ca4841a0c204334120c5e9d` + +The selected package supplies .NET 6 and .NET Standard 2.1 assets, both +compatible with acdream's .NET 10 App target. The v2.1.0 source was inspected +for the exact API used by the adapter: + +- `World.Create` +- generic `World.Create` +- `World.Set` / `World.Get` +- `World.Destroy(Entity)` / `World.Destroy(World)` +- `World.Size`, `World.Capacity`, and `World.Archetypes` +- archetype chunk count and chunk-size accounting + +The resolved dependency graph was captured with +`dotnet list package --include-transitive`. It resolves: + +```text +Arch 2.1.0 +Arch.LowLevel 1.1.5 +Collections.Pooled 2.0.0-preview.27 +CommunityToolkit.HighPerformance 8.4.0 +Microsoft.Extensions.ObjectPool 7.0.0 +ZeroAllocJobScheduler 1.1.2 +``` + +No package in the resolved App graph was reported vulnerable by NuGet's +`--vulnerable --include-transitive` audit on 2026-07-24. The transitive job +scheduler is not initialized or called by acdream; Slice F uses only +single-threaded structural operations and direct component access on the +existing update thread. + +## Containment proof + +`RenderSceneArchitectureTests` enforces all of the following: + +1. `Arch` is a direct package reference in `AcDream.App` only. +2. Arch namespaces occur only in the contained adapter folder. +3. Reflection finds no Arch field, property, parameter, or return type outside + that namespace. +4. Raw wrapper identity extraction occurs only in the adapter (apart from the + private contract declarations themselves). + +This is storage selection, not simulation architecture. `LiveEntityRuntime`, +`GpuWorldState`, gameplay identities, retail PView, GL ownership, and all +behavioral owners remain authoritative and unchanged. diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index aef6a729..bba735d3 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -23,6 +23,9 @@ ObjectMeshManager.cs (extracted in T2). --> + + diff --git a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs new file mode 100644 index 00000000..c835a582 --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs @@ -0,0 +1,634 @@ +using System.Runtime.CompilerServices; +using Arch.Core; +using ArchWorld = Arch.Core.World; + +namespace AcDream.App.Rendering.Scene.Arch; + +/// +/// Contained Arch-backed implementation of acdream's derived render +/// projection. Arch identities never leave this namespace; callers only see +/// generation-checked acdream value types. +/// +/// Slice F1 deliberately leaves this owner unbound and non-drawing. +/// +internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource +{ + private const int InitialEntityCapacity = 256; + private const int InitialArchetypeCapacity = 8; + + private readonly int _ownerThreadId; + private readonly Dictionary _entries = []; + private ArchWorld _world; + private RenderProjectionCounts _counts; + private ulong _lastAppliedJournalSequence; + private bool _disposed; + + public ArchRenderScene(RenderSceneGeneration initialGeneration) + { + Generation = initialGeneration; + _ownerThreadId = Environment.CurrentManagedThreadId; + _world = CreateWorld(); + } + + public RenderSceneGeneration Generation { get; private set; } + + public RenderProjectionCounts Counts + { + get + { + EnsureAvailable(); + return _counts; + } + } + + public RenderSceneMemoryAccounting Memory + { + get + { + EnsureAvailable(); + + int allocatedChunkCount = 0; + long estimatedChunkPayloadBytes = 0; + foreach (Archetype archetype in _world.Archetypes.AsSpan()) + { + allocatedChunkCount += archetype.ChunkCount; + estimatedChunkPayloadBytes += + (long)archetype.ChunkSize * archetype.ChunkCount; + } + + int lookupCapacity = _entries.EnsureCapacity(0); + long lookupBytes = + (long)lookupCapacity * Unsafe.SizeOf(); + + return new RenderSceneMemoryAccounting( + EntityCount: _world.Size, + ArchEntityCapacity: _world.Capacity, + ArchetypeCount: _world.Archetypes.Count, + AllocatedChunkCount: allocatedChunkCount, + EstimatedChunkPayloadBytes: estimatedChunkPayloadBytes, + ProjectionLookupCapacity: lookupCapacity, + EstimatedProjectionLookupBytes: lookupBytes, + EstimatedIndexBytes: 0, + EstimatedJournalBufferBytes: 0, + EstimatedSynchronizationSourceBytes: 0); + } + } + + public RenderDeltaApplyResult Apply( + ReadOnlySpan deltas) + { + EnsureMutationThread(); + var result = new ApplyResultBuilder(); + + foreach (ref readonly RenderProjectionDelta delta in deltas) + { + if (delta.Generation != Generation) + { + result.RejectedGeneration++; + continue; + } + + if (delta.JournalSequence == 0 + || delta.JournalSequence <= _lastAppliedJournalSequence) + { + result.RejectedOutOfOrderSequence++; + continue; + } + + _lastAppliedJournalSequence = delta.JournalSequence; + RenderProjectionRecord record = delta.Record; + + switch (delta.Kind) + { + case RenderProjectionDeltaKind.Register: + ApplyRegister(in record, ref result); + break; + case RenderProjectionDeltaKind.UpdateTransform: + case RenderProjectionDeltaKind.UpdateAppearance: + case RenderProjectionDeltaKind.UpdateFlags: + case RenderProjectionDeltaKind.Rebucket: + ApplyUpdate(delta.Kind, in record, ref result); + break; + case RenderProjectionDeltaKind.Unregister: + ApplyUnregister(in record, ref result); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(delta.Kind), + delta.Kind, + null); + } + } + + return result.Build(); + } + + public void SynchronizeDynamicSources( + in DynamicProjectionSyncInput input) + { + EnsureMutationThread(); + if (input.Generation != Generation) + { + throw new InvalidOperationException( + $"Dynamic synchronization belongs to {input.Generation}, " + + $"but the scene is {Generation}."); + } + + foreach (ref readonly DynamicProjectionUpdate update in input.Updates) + { + if (!_entries.TryGetValue(update.Id, out SceneEntry entry) + || entry.OwnerIncarnation != update.OwnerIncarnation) + { + continue; + } + + ref RenderTransform current = + ref _world.Get(entry.Entity); + ref RenderWorldBounds bounds = + ref _world.Get(entry.Entity); + if (current == update.Transform && bounds == update.Bounds) + continue; + + _world.Set( + entry.Entity, + new PreviousRenderTransform(current.LocalToWorld)); + _world.Set(entry.Entity, update.Transform); + _world.Set(entry.Entity, update.Bounds); + + ref RenderDirtyMask dirty = + ref _world.Get(entry.Entity); + dirty |= RenderDirtyMask.Transform | RenderDirtyMask.WorldBounds; + } + } + + public RenderSceneDigest BuildDigest(RenderSceneDigestBuffer reuse) + { + ArgumentNullException.ThrowIfNull(reuse); + EnsureAvailable(); + + List records = reuse.Records; + records.Clear(); + if (records.Capacity < _entries.Count) + records.Capacity = _entries.Count; + + foreach (SceneEntry entry in _entries.Values) + records.Add(ReadRecord(in entry)); + + records.Sort(RenderProjectionRecordComparer.Instance); + + StableRenderHash128 hash = StableRenderHash128.Create(); + hash.Add(Generation.RawValue); + hash.Add(records.Count); + foreach (RenderProjectionRecord record in records) + AddRecord(ref hash, in record); + + return new RenderSceneDigest(Generation, _counts, hash.Finish()); + } + + public RenderSceneQuery OpenQuery() + { + EnsureAvailable(); + return new RenderSceneQuery(this, Generation); + } + + public void Clear(RenderSceneGeneration replacementGeneration) + { + EnsureMutationThread(); + if (replacementGeneration.CompareTo(Generation) <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(replacementGeneration), + replacementGeneration, + "A replacement render-scene generation must advance."); + } + + ArchWorld.Destroy(_world); + _world = CreateWorld(); + _entries.Clear(); + _counts = default; + _lastAppliedJournalSequence = 0; + Generation = replacementGeneration; + } + + public void Dispose() + { + if (_disposed) + return; + + EnsureMutationThread(); + ArchWorld.Destroy(_world); + _entries.Clear(); + _counts = default; + _disposed = true; + } + + RenderProjectionCounts IRenderSceneQuerySource.GetCounts( + RenderSceneGeneration generation) + { + EnsureQueryGeneration(generation); + return _counts; + } + + bool IRenderSceneQuerySource.TryGet( + RenderSceneGeneration generation, + RenderProjectionId id, + out RenderProjectionRecord record) + { + EnsureQueryGeneration(generation); + if (_entries.TryGetValue(id, out SceneEntry entry)) + { + record = ReadRecord(in entry); + return true; + } + + record = default; + return false; + } + + int IRenderSceneQuerySource.CopyTo( + RenderSceneGeneration generation, + RenderProjectionClass? projectionClass, + Span destination) + { + EnsureQueryGeneration(generation); + int required = projectionClass.HasValue + ? _counts.For(projectionClass.Value) + : _counts.Total; + if (destination.Length < required) + { + throw new ArgumentException( + $"Destination holds {destination.Length} records; {required} required.", + nameof(destination)); + } + + int count = 0; + foreach (SceneEntry entry in _entries.Values) + { + if (projectionClass.HasValue + && entry.ProjectionClass != projectionClass.Value) + { + continue; + } + + destination[count++] = ReadRecord(in entry); + } + + return count; + } + + private static ArchWorld CreateWorld() => + ArchWorld.Create( + archetypeCapacity: InitialArchetypeCapacity, + entityCapacity: InitialEntityCapacity); + + private void ApplyRegister( + in RenderProjectionRecord record, + ref ApplyResultBuilder result) + { + if (_entries.TryGetValue(record.Id, out SceneEntry existing)) + { + int incarnationOrder = + record.OwnerIncarnation.CompareTo(existing.OwnerIncarnation); + if (incarnationOrder < 0) + { + result.RejectedStaleIncarnation++; + return; + } + + if (incarnationOrder == 0 + && record.ProjectionClass == existing.ProjectionClass) + { + WriteRecord(existing.Entity, in record); + result.Applied++; + result.Updated++; + return; + } + + Destroy(in existing); + result.Replaced++; + } + + Entity entity = CreateEntity(in record); + _entries[record.Id] = new SceneEntry( + entity, + record.OwnerIncarnation, + record.ProjectionClass); + IncrementCount(record.ProjectionClass); + result.Applied++; + result.Registered++; + } + + private void ApplyUpdate( + RenderProjectionDeltaKind kind, + in RenderProjectionRecord record, + ref ApplyResultBuilder result) + { + if (!TryGetCurrent(in record, ref result, out SceneEntry entry)) + return; + + switch (kind) + { + case RenderProjectionDeltaKind.UpdateTransform: + _world.Set(entry.Entity, record.PreviousTransform); + _world.Set(entry.Entity, record.Transform); + _world.Set(entry.Entity, record.Bounds); + _world.Set(entry.Entity, record.SortKey); + OrDirty( + entry.Entity, + RenderDirtyMask.Transform + | RenderDirtyMask.WorldBounds + | RenderDirtyMask.SortKey); + break; + case RenderProjectionDeltaKind.UpdateAppearance: + _world.Set(entry.Entity, record.MeshSet); + _world.Set(entry.Entity, record.Material); + _world.Set(entry.Entity, record.DegradeState); + OrDirty(entry.Entity, RenderDirtyMask.Appearance); + break; + case RenderProjectionDeltaKind.UpdateFlags: + _world.Set(entry.Entity, record.Flags); + OrDirty(entry.Entity, RenderDirtyMask.Flags); + break; + case RenderProjectionDeltaKind.Rebucket: + _world.Set(entry.Entity, record.Residency); + OrDirty(entry.Entity, RenderDirtyMask.SpatialResidency); + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind), kind, null); + } + + result.Applied++; + result.Updated++; + } + + private void ApplyUnregister( + in RenderProjectionRecord record, + ref ApplyResultBuilder result) + { + if (!TryGetCurrent(in record, ref result, out SceneEntry entry)) + return; + + Destroy(in entry); + _entries.Remove(record.Id); + result.Applied++; + result.Unregistered++; + } + + private bool TryGetCurrent( + in RenderProjectionRecord record, + ref ApplyResultBuilder result, + out SceneEntry entry) + { + if (!_entries.TryGetValue(record.Id, out entry)) + { + result.RejectedMissing++; + return false; + } + + if (record.OwnerIncarnation != entry.OwnerIncarnation) + { + result.RejectedStaleIncarnation++; + return false; + } + + return true; + } + + private Entity CreateEntity(in RenderProjectionRecord record) => + record.ProjectionClass switch + { + RenderProjectionClass.OutdoorStatic => + CreateEntity(in record, new OutdoorStaticTag()), + RenderProjectionClass.IndoorCellStatic => + CreateEntity(in record, new IndoorCellStaticTag()), + RenderProjectionClass.LiveDynamicRoot => + CreateEntity(in record, new LiveDynamicRootTag()), + RenderProjectionClass.ActiveAnimatedStatic => + CreateEntity(in record, new ActiveAnimatedStaticTag()), + RenderProjectionClass.EquippedChild => + CreateEntity(in record, new EquippedChildTag()), + _ => throw new ArgumentOutOfRangeException( + nameof(record.ProjectionClass), + record.ProjectionClass, + null), + }; + + private Entity CreateEntity( + in RenderProjectionRecord record, + TTag tag) + where TTag : struct => + _world.Create( + new ProjectionIdentity(record.Id), + record.Transform, + record.PreviousTransform, + record.MeshSet, + record.Material, + record.Residency, + record.Bounds, + record.Flags, + record.DegradeState, + record.SortKey, + record.OwnerIncarnation, + record.DirtyMask, + tag); + + private void WriteRecord( + Entity entity, + in RenderProjectionRecord record) + { + _world.Set(entity, record.Transform); + _world.Set(entity, record.PreviousTransform); + _world.Set(entity, record.MeshSet); + _world.Set(entity, record.Material); + _world.Set(entity, record.Residency); + _world.Set(entity, record.Bounds); + _world.Set(entity, record.Flags); + _world.Set(entity, record.DegradeState); + _world.Set(entity, record.SortKey); + _world.Set(entity, record.OwnerIncarnation); + _world.Set(entity, record.DirtyMask); + } + + private RenderProjectionRecord ReadRecord(in SceneEntry entry) => + new( + _world.Get(entry.Entity).Id, + entry.ProjectionClass, + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity), + _world.Get(entry.Entity)); + + private void Destroy(in SceneEntry entry) + { + _world.Destroy(entry.Entity); + DecrementCount(entry.ProjectionClass); + } + + private void OrDirty(Entity entity, RenderDirtyMask value) + { + ref RenderDirtyMask dirty = ref _world.Get(entity); + dirty |= value; + } + + private void IncrementCount(RenderProjectionClass projectionClass) + { + _counts = WithClassCount( + _counts, + projectionClass, + _counts.For(projectionClass) + 1, + _counts.Total + 1); + } + + private void DecrementCount(RenderProjectionClass projectionClass) + { + _counts = WithClassCount( + _counts, + projectionClass, + _counts.For(projectionClass) - 1, + _counts.Total - 1); + } + + private static RenderProjectionCounts WithClassCount( + RenderProjectionCounts counts, + RenderProjectionClass projectionClass, + int classCount, + int total) => + projectionClass switch + { + RenderProjectionClass.OutdoorStatic => + counts with { Total = total, OutdoorStatic = classCount }, + RenderProjectionClass.IndoorCellStatic => + counts with { Total = total, IndoorCellStatic = classCount }, + RenderProjectionClass.LiveDynamicRoot => + counts with { Total = total, LiveDynamicRoot = classCount }, + RenderProjectionClass.ActiveAnimatedStatic => + counts with { Total = total, ActiveAnimatedStatic = classCount }, + RenderProjectionClass.EquippedChild => + counts with { Total = total, EquippedChild = classCount }, + _ => throw new ArgumentOutOfRangeException( + nameof(projectionClass), + projectionClass, + null), + }; + + private void EnsureMutationThread() + { + EnsureAvailable(); + if (Environment.CurrentManagedThreadId != _ownerThreadId) + { + throw new InvalidOperationException( + "Render-scene mutation must remain on its owning update thread."); + } + } + + private void EnsureQueryGeneration(RenderSceneGeneration generation) + { + EnsureAvailable(); + if (generation != Generation) + { + throw new InvalidOperationException( + $"Borrowed render-scene query belongs to {generation}; " + + $"the current scene is {Generation}."); + } + } + + private void EnsureAvailable() => + ObjectDisposedException.ThrowIf(_disposed, this); + + private static void AddRecord( + ref StableRenderHash128 hash, + in RenderProjectionRecord record) + { + hash.Add(record.Id.RawValue); + hash.Add((byte)record.ProjectionClass); + hash.Add(record.OwnerIncarnation.RawValue); + hash.Add(record.Transform.LocalToWorld); + hash.Add(record.PreviousTransform.LocalToWorld); + hash.Add(record.MeshSet.Handle.RawValue); + hash.Add(record.MeshSet.MeshCount); + hash.Add(record.MeshSet.Revision); + hash.Add(record.Material.PaletteKey); + hash.Add(record.Material.TextureReplacementKey); + hash.Add(record.Material.Opacity); + hash.Add(record.Residency.Bucket.RawValue); + hash.Add(record.Residency.OwnerLandblockId); + hash.Add(record.Residency.FullCellId); + hash.Add(record.Bounds.Minimum); + hash.Add(record.Bounds.Maximum); + hash.Add((uint)record.Flags); + hash.Add(record.DegradeState.Level); + hash.Add(record.DegradeState.Revision); + hash.Add(record.SortKey.Value); + } + + private readonly record struct ProjectionIdentity(RenderProjectionId Id); + + private readonly record struct OutdoorStaticTag; + + private readonly record struct IndoorCellStaticTag; + + private readonly record struct LiveDynamicRootTag; + + private readonly record struct ActiveAnimatedStaticTag; + + private readonly record struct EquippedChildTag; + + private readonly record struct SceneEntry( + Entity Entity, + RenderOwnerIncarnation OwnerIncarnation, + RenderProjectionClass ProjectionClass); + + private readonly record struct ProjectionLookupSlotEstimate( + int HashCode, + int Next, + RenderProjectionId Key, + SceneEntry Entry); + + private struct ApplyResultBuilder + { + public int Applied; + public int Registered; + public int Updated; + public int Replaced; + public int Unregistered; + public int RejectedGeneration; + public int RejectedOutOfOrderSequence; + public int RejectedStaleIncarnation; + public int RejectedMissing; + + public readonly RenderDeltaApplyResult Build() => + new( + Applied, + Registered, + Updated, + Replaced, + Unregistered, + RejectedGeneration, + RejectedOutOfOrderSequence, + RejectedStaleIncarnation, + RejectedMissing); + } + + private sealed class RenderProjectionRecordComparer : + IComparer + { + public static RenderProjectionRecordComparer Instance { get; } = new(); + + public int Compare( + RenderProjectionRecord x, + RenderProjectionRecord y) + { + int value = x.Id.CompareTo(y.Id); + return value != 0 + ? value + : x.ProjectionClass.CompareTo(y.ProjectionClass); + } + } +} diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs new file mode 100644 index 00000000..8bb31d5c --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs @@ -0,0 +1,381 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Scene; + +internal readonly record struct RenderProjectionId +{ + private readonly ulong _value; + + private RenderProjectionId(ulong value) => _value = value; + + internal static RenderProjectionId FromRaw(ulong value) => new(value); + + internal ulong RawValue => _value; + + public int CompareTo(RenderProjectionId other) => + _value.CompareTo(other._value); + + public override string ToString() => $"projection:{_value:X16}"; +} + +internal readonly record struct RenderOwnerIncarnation +{ + private readonly ulong _value; + + private RenderOwnerIncarnation(ulong value) => _value = value; + + internal static RenderOwnerIncarnation FromRaw(ulong value) => new(value); + + internal ulong RawValue => _value; + + public int CompareTo(RenderOwnerIncarnation other) => + _value.CompareTo(other._value); + + public override string ToString() => $"incarnation:{_value}"; +} + +internal readonly record struct RenderSceneGeneration +{ + private readonly ulong _value; + + private RenderSceneGeneration(ulong value) => _value = value; + + internal static RenderSceneGeneration FromRaw(ulong value) => new(value); + + internal ulong RawValue => _value; + + public int CompareTo(RenderSceneGeneration other) => + _value.CompareTo(other._value); + + public override string ToString() => $"generation:{_value}"; +} + +internal readonly record struct RenderSpatialBucket +{ + private readonly ulong _value; + + private RenderSpatialBucket(ulong value) => _value = value; + + internal static RenderSpatialBucket FromRaw(ulong value) => new(value); + + internal ulong RawValue => _value; + + public int CompareTo(RenderSpatialBucket other) => + _value.CompareTo(other._value); + + public override string ToString() => $"bucket:{_value:X16}"; +} + +internal readonly record struct RenderAssetHandle +{ + private readonly ulong _value; + + private RenderAssetHandle(ulong value) => _value = value; + + internal static RenderAssetHandle FromRaw(ulong value) => new(value); + + internal ulong RawValue => _value; + + public int CompareTo(RenderAssetHandle other) => + _value.CompareTo(other._value); + + public override string ToString() => $"asset:{_value:X16}"; +} + +internal enum RenderProjectionClass : byte +{ + OutdoorStatic, + IndoorCellStatic, + LiveDynamicRoot, + ActiveAnimatedStatic, + EquippedChild, +} + +[Flags] +internal enum RenderProjectionFlags : uint +{ + None = 0, + Draw = 1 << 0, + Hidden = 1 << 1, + AncestorHidden = 1 << 2, + Translucent = 1 << 3, + Selectable = 1 << 4, + LightCandidate = 1 << 5, + PortalStraddling = 1 << 6, +} + +[Flags] +internal enum RenderDirtyMask : ushort +{ + None = 0, + Transform = 1 << 0, + Appearance = 1 << 1, + Flags = 1 << 2, + SpatialResidency = 1 << 3, + WorldBounds = 1 << 4, + SortKey = 1 << 5, + All = ushort.MaxValue, +} + +internal readonly record struct RenderTransform(Matrix4x4 LocalToWorld); + +internal readonly record struct PreviousRenderTransform(Matrix4x4 LocalToWorld); + +internal readonly record struct RenderMeshSet( + RenderAssetHandle Handle, + int MeshCount, + ulong Revision); + +internal readonly record struct RenderMaterialVariant( + ulong PaletteKey, + ulong TextureReplacementKey, + float Opacity); + +internal readonly record struct RenderSpatialResidency( + RenderSpatialBucket Bucket, + uint OwnerLandblockId, + uint FullCellId); + +internal readonly record struct RenderWorldBounds(Vector3 Minimum, Vector3 Maximum); + +internal readonly record struct RenderDegradeState(byte Level, uint Revision); + +internal readonly record struct RenderSortKey(ulong Value); + +internal readonly record struct RenderProjectionRecord( + RenderProjectionId Id, + RenderProjectionClass ProjectionClass, + RenderOwnerIncarnation OwnerIncarnation, + RenderTransform Transform, + PreviousRenderTransform PreviousTransform, + RenderMeshSet MeshSet, + RenderMaterialVariant Material, + RenderSpatialResidency Residency, + RenderWorldBounds Bounds, + RenderProjectionFlags Flags, + RenderDegradeState DegradeState, + RenderSortKey SortKey, + RenderDirtyMask DirtyMask); + +internal enum RenderProjectionDeltaKind : byte +{ + Register, + UpdateTransform, + UpdateAppearance, + UpdateFlags, + Rebucket, + Unregister, +} + +internal readonly record struct RenderProjectionDelta( + RenderProjectionDeltaKind Kind, + RenderSceneGeneration Generation, + ulong JournalSequence, + RenderProjectionRecord Record) +{ + public static RenderProjectionDelta Register( + RenderSceneGeneration generation, + ulong journalSequence, + in RenderProjectionRecord record) => + new( + RenderProjectionDeltaKind.Register, + generation, + journalSequence, + record); + + public static RenderProjectionDelta Update( + RenderProjectionDeltaKind kind, + RenderSceneGeneration generation, + ulong journalSequence, + in RenderProjectionRecord record) + { + if (kind is RenderProjectionDeltaKind.Register + or RenderProjectionDeltaKind.Unregister) + { + throw new ArgumentOutOfRangeException( + nameof(kind), + kind, + "Use Register or Unregister for structural deltas."); + } + + return new RenderProjectionDelta(kind, generation, journalSequence, record); + } + + public static RenderProjectionDelta Unregister( + RenderSceneGeneration generation, + ulong journalSequence, + RenderProjectionId id, + RenderOwnerIncarnation ownerIncarnation) => + new( + RenderProjectionDeltaKind.Unregister, + generation, + journalSequence, + new RenderProjectionRecord() with + { + Id = id, + OwnerIncarnation = ownerIncarnation, + }); +} + +internal readonly record struct DynamicProjectionUpdate( + RenderProjectionId Id, + RenderOwnerIncarnation OwnerIncarnation, + RenderTransform Transform, + RenderWorldBounds Bounds); + +internal readonly ref struct DynamicProjectionSyncInput +{ + public DynamicProjectionSyncInput( + RenderSceneGeneration generation, + ReadOnlySpan updates) + { + Generation = generation; + Updates = updates; + } + + public RenderSceneGeneration Generation { get; } + + public ReadOnlySpan Updates { get; } +} + +internal readonly record struct RenderProjectionCounts( + int Total, + int OutdoorStatic, + int IndoorCellStatic, + int LiveDynamicRoot, + int ActiveAnimatedStatic, + int EquippedChild) +{ + public int For(RenderProjectionClass projectionClass) => + projectionClass switch + { + RenderProjectionClass.OutdoorStatic => OutdoorStatic, + RenderProjectionClass.IndoorCellStatic => IndoorCellStatic, + RenderProjectionClass.LiveDynamicRoot => LiveDynamicRoot, + RenderProjectionClass.ActiveAnimatedStatic => ActiveAnimatedStatic, + RenderProjectionClass.EquippedChild => EquippedChild, + _ => throw new ArgumentOutOfRangeException( + nameof(projectionClass), + projectionClass, + null), + }; +} + +internal readonly record struct RenderDeltaApplyResult( + int Applied, + int Registered, + int Updated, + int Replaced, + int Unregistered, + int RejectedGeneration, + int RejectedOutOfOrderSequence, + int RejectedStaleIncarnation, + int RejectedMissing) +{ + public int Rejected => + RejectedGeneration + + RejectedOutOfOrderSequence + + RejectedStaleIncarnation + + RejectedMissing; +} + +internal readonly record struct RenderSceneMemoryAccounting( + int EntityCount, + int ArchEntityCapacity, + int ArchetypeCount, + int AllocatedChunkCount, + long EstimatedChunkPayloadBytes, + int ProjectionLookupCapacity, + long EstimatedProjectionLookupBytes, + long EstimatedIndexBytes, + long EstimatedJournalBufferBytes, + long EstimatedSynchronizationSourceBytes) +{ + public long TotalEstimatedBytes => + EstimatedChunkPayloadBytes + + EstimatedProjectionLookupBytes + + EstimatedIndexBytes + + EstimatedJournalBufferBytes + + EstimatedSynchronizationSourceBytes; +} + +internal readonly record struct RenderSceneDigest( + RenderSceneGeneration Generation, + RenderProjectionCounts Counts, + RenderSceneHash128 Hash); + +internal sealed class RenderSceneDigestBuffer +{ + internal List Records { get; } = []; + + internal int Capacity => Records.Capacity; +} + +internal interface IRenderSceneQuerySource +{ + RenderProjectionCounts GetCounts(RenderSceneGeneration generation); + + bool TryGet( + RenderSceneGeneration generation, + RenderProjectionId id, + out RenderProjectionRecord record); + + int CopyTo( + RenderSceneGeneration generation, + RenderProjectionClass? projectionClass, + Span destination); +} + +internal readonly struct RenderSceneQuery +{ + private readonly IRenderSceneQuerySource? _source; + + internal RenderSceneQuery( + IRenderSceneQuerySource source, + RenderSceneGeneration generation) + { + _source = source; + Generation = generation; + } + + public RenderSceneGeneration Generation { get; } + + public RenderProjectionCounts Counts => + Source.GetCounts(Generation); + + public bool TryGet( + RenderProjectionId id, + out RenderProjectionRecord record) => + Source.TryGet(Generation, id, out record); + + public int CopyTo(Span destination) => + Source.CopyTo(Generation, null, destination); + + public int CopyTo( + RenderProjectionClass projectionClass, + Span destination) => + Source.CopyTo(Generation, projectionClass, destination); + + private IRenderSceneQuerySource Source => + _source + ?? throw new InvalidOperationException("The render-scene query is uninitialized."); +} + +internal interface IRenderScene : IDisposable +{ + RenderSceneGeneration Generation { get; } + + RenderProjectionCounts Counts { get; } + + RenderSceneMemoryAccounting Memory { get; } + + RenderDeltaApplyResult Apply(ReadOnlySpan deltas); + + void SynchronizeDynamicSources(in DynamicProjectionSyncInput input); + + RenderSceneDigest BuildDigest(RenderSceneDigestBuffer reuse); + + RenderSceneQuery OpenQuery(); + + void Clear(RenderSceneGeneration replacementGeneration); +} diff --git a/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs new file mode 100644 index 00000000..058784ed --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ArchRenderSceneTests.cs @@ -0,0 +1,405 @@ +using System.Numerics; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Scene.Arch; + +namespace AcDream.App.Tests.Rendering; + +public sealed class ArchRenderSceneTests +{ + [Fact] + public void Apply_RegisterChannelUpdatesAndUnregister_PreservesTypedRecord() + { + RenderSceneGeneration generation = Generation(1); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord original = Record( + id: 10, + incarnation: 3, + RenderProjectionClass.LiveDynamicRoot, + x: 1, + bucket: 0xAABB); + + RenderDeltaApplyResult register = scene.Apply( + [RenderProjectionDelta.Register(generation, 1, original)]); + + Assert.Equal(1, register.Applied); + Assert.Equal(1, register.Registered); + Assert.Equal( + new RenderProjectionCounts(1, 0, 0, 1, 0, 0), + scene.Counts); + + RenderProjectionRecord transformed = original with + { + PreviousTransform = new PreviousRenderTransform( + original.Transform.LocalToWorld), + Transform = new RenderTransform( + Matrix4x4.CreateTranslation(4, 5, 6)), + Bounds = new RenderWorldBounds( + new Vector3(3, 4, 5), + new Vector3(5, 6, 7)), + SortKey = new RenderSortKey(99), + }; + RenderProjectionRecord appearance = transformed with + { + MeshSet = new RenderMeshSet(Asset(80), 4, 7), + Material = new RenderMaterialVariant(12, 13, 0.5f), + DegradeState = new RenderDegradeState(2, 8), + }; + RenderProjectionRecord flags = appearance with + { + Flags = RenderProjectionFlags.Draw + | RenderProjectionFlags.Translucent, + }; + RenderProjectionRecord rebucketed = flags with + { + Residency = new RenderSpatialResidency( + Bucket(0xCCDD), + 0xCCDD0000, + 0xCCDD0102), + }; + + RenderDeltaApplyResult updates = scene.Apply( + [ + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateTransform, + generation, + 2, + transformed), + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateAppearance, + generation, + 3, + appearance), + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateFlags, + generation, + 4, + flags), + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.Rebucket, + generation, + 5, + rebucketed), + ]); + + Assert.Equal(4, updates.Applied); + Assert.Equal(4, updates.Updated); + Assert.True(scene.OpenQuery().TryGet(original.Id, out var stored)); + Assert.Equal(rebucketed.Transform, stored.Transform); + Assert.Equal(rebucketed.Bounds, stored.Bounds); + Assert.Equal(rebucketed.MeshSet, stored.MeshSet); + Assert.Equal(rebucketed.Material, stored.Material); + Assert.Equal(rebucketed.Flags, stored.Flags); + Assert.Equal(rebucketed.Residency, stored.Residency); + Assert.Equal( + RenderDirtyMask.All, + stored.DirtyMask); + + RenderDeltaApplyResult unregister = scene.Apply( + [ + RenderProjectionDelta.Unregister( + generation, + 6, + original.Id, + original.OwnerIncarnation), + ]); + + Assert.Equal(1, unregister.Applied); + Assert.Equal(1, unregister.Unregistered); + Assert.Equal(default, scene.Counts); + Assert.False(scene.OpenQuery().TryGet(original.Id, out _)); + } + + [Fact] + public void Register_NewerIncarnationReplacesAndOldCallbacksCannotRemoveIt() + { + RenderSceneGeneration generation = Generation(4); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord first = Record( + 44, + 10, + RenderProjectionClass.OutdoorStatic); + RenderProjectionRecord replacement = Record( + 44, + 11, + RenderProjectionClass.ActiveAnimatedStatic, + x: 9); + + scene.Apply([RenderProjectionDelta.Register(generation, 1, first)]); + RenderDeltaApplyResult replaced = scene.Apply( + [RenderProjectionDelta.Register(generation, 2, replacement)]); + RenderDeltaApplyResult staleRegister = scene.Apply( + [RenderProjectionDelta.Register(generation, 3, first)]); + RenderDeltaApplyResult staleDelete = scene.Apply( + [ + RenderProjectionDelta.Unregister( + generation, + 4, + first.Id, + first.OwnerIncarnation), + ]); + + Assert.Equal(1, replaced.Replaced); + Assert.Equal(1, replaced.Registered); + Assert.Equal(1, staleRegister.RejectedStaleIncarnation); + Assert.Equal(1, staleDelete.RejectedStaleIncarnation); + Assert.Equal( + new RenderProjectionCounts(1, 0, 0, 0, 1, 0), + scene.Counts); + Assert.True(scene.OpenQuery().TryGet(first.Id, out var current)); + Assert.Equal(replacement.OwnerIncarnation, current.OwnerIncarnation); + Assert.Equal(replacement.Transform, current.Transform); + } + + [Fact] + public void Apply_RejectsWrongGenerationOutOfOrderAndMissingDeltas() + { + RenderSceneGeneration generation = Generation(7); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord record = Record( + 70, + 1, + RenderProjectionClass.IndoorCellStatic); + + RenderDeltaApplyResult result = scene.Apply( + [ + RenderProjectionDelta.Register(Generation(6), 1, record), + RenderProjectionDelta.Register(generation, 2, record), + RenderProjectionDelta.Update( + RenderProjectionDeltaKind.UpdateFlags, + generation, + 2, + record), + RenderProjectionDelta.Unregister( + generation, + 3, + Projection(999), + Incarnation(1)), + ]); + + Assert.Equal(1, result.Applied); + Assert.Equal(1, result.RejectedGeneration); + Assert.Equal(1, result.RejectedOutOfOrderSequence); + Assert.Equal(1, result.RejectedMissing); + Assert.Equal(1, scene.Counts.Total); + } + + [Fact] + public void Clear_AdvancesGenerationReclaimsRecordsAndInvalidatesBorrowedQuery() + { + RenderSceneGeneration firstGeneration = Generation(1); + using var scene = new ArchRenderScene(firstGeneration); + RenderProjectionRecord record = Record( + 1, + 1, + RenderProjectionClass.EquippedChild); + scene.Apply( + [RenderProjectionDelta.Register(firstGeneration, 1, record)]); + RenderSceneQuery borrowed = scene.OpenQuery(); + + scene.Clear(Generation(2)); + + Assert.Equal(Generation(2), scene.Generation); + Assert.Equal(default, scene.Counts); + Assert.Equal(0, scene.Memory.EntityCount); + Assert.Throws(() => _ = borrowed.Counts); + Assert.Throws( + () => scene.Clear(Generation(2))); + + RenderProjectionRecord next = record with + { + OwnerIncarnation = Incarnation(2), + }; + RenderDeltaApplyResult applied = scene.Apply( + [RenderProjectionDelta.Register(Generation(2), 1, next)]); + Assert.Equal(1, applied.Registered); + } + + [Fact] + public void FiveProjectionClasses_UseNarrowArchetypesAndReportRetainedMemory() + { + RenderSceneGeneration generation = Generation(9); + using var scene = new ArchRenderScene(generation); + RenderProjectionClass[] classes = + [ + RenderProjectionClass.OutdoorStatic, + RenderProjectionClass.IndoorCellStatic, + RenderProjectionClass.LiveDynamicRoot, + RenderProjectionClass.ActiveAnimatedStatic, + RenderProjectionClass.EquippedChild, + ]; + var deltas = new RenderProjectionDelta[classes.Length]; + for (int i = 0; i < classes.Length; i++) + { + RenderProjectionRecord record = Record( + (ulong)i + 1, + 1, + classes[i]); + deltas[i] = RenderProjectionDelta.Register( + generation, + (ulong)i + 1, + record); + } + + scene.Apply(deltas); + RenderSceneMemoryAccounting memory = scene.Memory; + + Assert.Equal(5, scene.Counts.Total); + Assert.Equal(5, memory.EntityCount); + Assert.Equal(5, memory.ArchetypeCount); + Assert.Equal(5, memory.AllocatedChunkCount); + Assert.True(memory.ArchEntityCapacity >= memory.EntityCount); + Assert.True(memory.EstimatedChunkPayloadBytes > 0); + Assert.True(memory.ProjectionLookupCapacity >= memory.EntityCount); + Assert.True(memory.EstimatedProjectionLookupBytes > 0); + Assert.Equal( + memory.EstimatedChunkPayloadBytes + + memory.EstimatedProjectionLookupBytes, + memory.TotalEstimatedBytes); + } + + [Fact] + public void DynamicSynchronization_UpdatesOnlyMatchingLiveIncarnation() + { + RenderSceneGeneration generation = Generation(10); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord record = Record( + 100, + 5, + RenderProjectionClass.LiveDynamicRoot); + scene.Apply([RenderProjectionDelta.Register(generation, 1, record)]); + + DynamicProjectionUpdate stale = new( + record.Id, + Incarnation(4), + new RenderTransform(Matrix4x4.CreateTranslation(100, 0, 0)), + record.Bounds); + DynamicProjectionUpdate current = new( + record.Id, + record.OwnerIncarnation, + new RenderTransform(Matrix4x4.CreateTranslation(8, 9, 10)), + new RenderWorldBounds( + new Vector3(7, 8, 9), + new Vector3(9, 10, 11))); + scene.SynchronizeDynamicSources( + new DynamicProjectionSyncInput(generation, [stale, current])); + + Assert.True(scene.OpenQuery().TryGet(record.Id, out var stored)); + Assert.Equal(record.Transform.LocalToWorld, stored.PreviousTransform.LocalToWorld); + Assert.Equal(current.Transform, stored.Transform); + Assert.Equal(current.Bounds, stored.Bounds); + } + + [Fact] + public void Digest_IsStableAcrossRegistrationOrderAndBufferReuse() + { + RenderSceneGeneration generation = Generation(12); + using var first = new ArchRenderScene(generation); + using var second = new ArchRenderScene(generation); + RenderProjectionRecord a = Record( + 1, + 1, + RenderProjectionClass.OutdoorStatic); + RenderProjectionRecord b = Record( + 2, + 1, + RenderProjectionClass.LiveDynamicRoot); + + first.Apply( + [ + RenderProjectionDelta.Register(generation, 1, a), + RenderProjectionDelta.Register(generation, 2, b), + ]); + second.Apply( + [ + RenderProjectionDelta.Register(generation, 1, b), + RenderProjectionDelta.Register(generation, 2, a), + ]); + var buffer = new RenderSceneDigestBuffer(); + + RenderSceneDigest firstDigest = first.BuildDigest(buffer); + int retainedCapacity = buffer.Capacity; + RenderSceneDigest repeatedDigest = first.BuildDigest(buffer); + RenderSceneDigest secondDigest = + second.BuildDigest(new RenderSceneDigestBuffer()); + + Assert.Equal(firstDigest, repeatedDigest); + Assert.Equal(firstDigest, secondDigest); + Assert.Equal(retainedCapacity, buffer.Capacity); + } + + [Fact] + public void Mutation_FromAnotherThread_IsRejected() + { + RenderSceneGeneration generation = Generation(15); + using var scene = new ArchRenderScene(generation); + RenderProjectionRecord record = Record( + 15, + 1, + RenderProjectionClass.LiveDynamicRoot); + + Exception? captured = null; + var mutationThread = new Thread(() => + { + try + { + scene.Apply( + [RenderProjectionDelta.Register(generation, 1, record)]); + } + catch (Exception error) + { + captured = error; + } + }); + mutationThread.Start(); + mutationThread.Join(); + + InvalidOperationException error = + Assert.IsType(captured); + Assert.Contains("owning update thread", error.Message); + Assert.Equal(0, scene.Counts.Total); + } + + private static RenderProjectionRecord Record( + ulong id, + ulong incarnation, + RenderProjectionClass projectionClass, + float x = 0, + ulong bucket = 1) + { + Matrix4x4 transform = Matrix4x4.CreateTranslation(x, 2, 3); + return new RenderProjectionRecord( + Projection(id), + projectionClass, + Incarnation(incarnation), + new RenderTransform(transform), + new PreviousRenderTransform(transform), + new RenderMeshSet(Asset(id + 100), 2, 1), + new RenderMaterialVariant(id + 200, id + 300, 1), + new RenderSpatialResidency( + Bucket(bucket), + (uint)(bucket << 16), + (uint)((bucket << 16) | 0x101)), + new RenderWorldBounds( + new Vector3(x - 1, 1, 2), + new Vector3(x + 1, 3, 4)), + RenderProjectionFlags.Draw | RenderProjectionFlags.Selectable, + new RenderDegradeState(0, 1), + new RenderSortKey(id + 400), + RenderDirtyMask.All); + } + + private static RenderProjectionId Projection(ulong value) => + RenderProjectionId.FromRaw(value); + + private static RenderOwnerIncarnation Incarnation(ulong value) => + RenderOwnerIncarnation.FromRaw(value); + + private static RenderSceneGeneration Generation(ulong value) => + RenderSceneGeneration.FromRaw(value); + + private static RenderSpatialBucket Bucket(ulong value) => + RenderSpatialBucket.FromRaw(value); + + private static RenderAssetHandle Asset(ulong value) => + RenderAssetHandle.FromRaw(value); +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderSceneArchitectureTests.cs b/tests/AcDream.App.Tests/Rendering/RenderSceneArchitectureTests.cs new file mode 100644 index 00000000..868cee7d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RenderSceneArchitectureTests.cs @@ -0,0 +1,184 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using AcDream.App.Rendering.Scene; + +namespace AcDream.App.Tests.Rendering; + +public sealed class RenderSceneArchitectureTests +{ + [Fact] + public void ArchDependency_IsPinnedOnlyInAppAndNamespacesStayContained() + { + string root = FindRepoRoot(); + string appRoot = Path.Combine(root, "src", "AcDream.App"); + string allowedRoot = Path.GetFullPath( + Path.Combine(appRoot, "Rendering", "Scene", "Arch")); + string[] appSources = + Directory.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories); + + string[] namespaceViolations = appSources + .Where(path => Regex.IsMatch( + File.ReadAllText(path), + @"(?:\busing\s+Arch(?:\.|;)|\bArch\.Core\b)")) + .Where(path => !IsUnder(path, allowedRoot)) + .Select(Path.GetFullPath) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + string[] packageOwners = Directory + .GetFiles(Path.Combine(root, "src"), "*.csproj", SearchOption.AllDirectories) + .Where(HasDirectArchReference) + .Select(Path.GetFullPath) + .ToArray(); + + Assert.Empty(namespaceViolations); + Assert.Equal( + [Path.GetFullPath(Path.Combine(appRoot, "AcDream.App.csproj"))], + packageOwners); + } + + [Fact] + public void ArchTypes_DoNotCrossAcdreamOwnedSceneBoundary() + { + Type[] appTypes = typeof(IRenderScene).Assembly.GetTypes(); + var violations = new List(); + + foreach (Type type in appTypes) + { + if (type.Namespace?.StartsWith( + "AcDream.App.Rendering.Scene.Arch", + StringComparison.Ordinal) + == true) + { + continue; + } + + foreach (FieldInfo field in type.GetFields( + BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly)) + { + if (ContainsArchType(field.FieldType)) + violations.Add($"{type.FullName} field {field.Name}"); + } + + foreach (PropertyInfo property in type.GetProperties( + BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly)) + { + if (ContainsArchType(property.PropertyType)) + violations.Add($"{type.FullName} property {property.Name}"); + } + + foreach (MethodBase method in type + .GetMethods( + BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly) + .Cast() + .Concat(type.GetConstructors( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic))) + { + if (method.GetParameters() + .Any(parameter => ContainsArchType(parameter.ParameterType))) + { + violations.Add($"{type.FullName} method {method.Name}"); + } + + if (method is MethodInfo info + && ContainsArchType(info.ReturnType)) + { + violations.Add($"{type.FullName} return {method.Name}"); + } + } + } + + Assert.Empty(violations); + } + + [Fact] + public void PrimitiveIdentityExtraction_StaysInsideArchAdapter() + { + string root = FindRepoRoot(); + string appRoot = Path.Combine(root, "src", "AcDream.App"); + string allowedRoot = Path.GetFullPath( + Path.Combine(appRoot, "Rendering", "Scene", "Arch")); + string contracts = Path.GetFullPath( + Path.Combine( + appRoot, + "Rendering", + "Scene", + "RenderSceneContracts.cs")); + + string[] violations = Directory + .GetFiles(appRoot, "*.cs", SearchOption.AllDirectories) + .Where(path => Regex.IsMatch( + File.ReadAllText(path), + @"\.\s*RawValue\b")) + .Where(path => + !IsUnder(path, allowedRoot) + && !Path.GetFullPath(path).Equals( + contracts, + StringComparison.OrdinalIgnoreCase)) + .Select(Path.GetFullPath) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Empty(violations); + } + + private static bool HasDirectArchReference(string projectPath) + { + XDocument project = XDocument.Load(projectPath); + return project + .Descendants("PackageReference") + .Any(element => string.Equals( + (string?)element.Attribute("Include"), + "Arch", + StringComparison.Ordinal)); + } + + private static bool ContainsArchType(Type type) + { + if (type.IsByRef || type.IsArray || type.IsPointer) + return ContainsArchType(type.GetElementType()!); + + if (type.Namespace?.StartsWith("Arch", StringComparison.Ordinal) == true) + return true; + + return type.IsGenericType + && type.GetGenericArguments().Any(ContainsArchType); + } + + private static bool IsUnder(string path, string root) + { + string relative = Path.GetRelativePath(root, Path.GetFullPath(path)); + return relative != ".." + && !relative.StartsWith( + $"..{Path.DirectorySeparatorChar}", + StringComparison.Ordinal); + } + + private static string FindRepoRoot() + { + string? dir = AppContext.BaseDirectory; + while (dir is not null) + { + if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) + return dir; + dir = Directory.GetParent(dir)?.FullName; + } + + throw new DirectoryNotFoundException("Could not locate AcDream.slnx."); + } +}