feat(rendering): contain incremental render scene

Pin Arch behind acdream-owned identities, deltas, queries, generation gates, and memory diagnostics. The new five-archetype store is unbound and non-drawing so static publication can be shadowed without changing production behavior.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
This commit is contained in:
Erik 2026-07-24 21:42:42 +02:00
parent f9b68f8f2a
commit dbd8318417
7 changed files with 1698 additions and 2 deletions

View file

@ -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. |
| F2F5 | 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. |
| F3F5 | pending | Live/equipped publication, dynamic indices, and continuous shadow comparison have not started. |
| G0G5 | 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

View file

@ -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<T...>`
- `World.Set<T>` / `World.Get<T>`
- `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.

View file

@ -23,6 +23,9 @@
ObjectMeshManager.cs (extracted in T2). -->
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<!-- Slice F: contained render-projection storage. Arch types are confined to
Rendering/Scene/Arch and never cross an acdream-owned interface. -->
<PackageReference Include="Arch" Version="2.1.0" />
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenGL.Extensions.ARB" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />

View file

@ -0,0 +1,634 @@
using System.Runtime.CompilerServices;
using Arch.Core;
using ArchWorld = Arch.Core.World;
namespace AcDream.App.Rendering.Scene.Arch;
/// <summary>
/// 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.
/// </summary>
internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
{
private const int InitialEntityCapacity = 256;
private const int InitialArchetypeCapacity = 8;
private readonly int _ownerThreadId;
private readonly Dictionary<RenderProjectionId, SceneEntry> _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<ProjectionLookupSlotEstimate>();
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<RenderProjectionDelta> 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<RenderTransform>(entry.Entity);
ref RenderWorldBounds bounds =
ref _world.Get<RenderWorldBounds>(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<RenderDirtyMask>(entry.Entity);
dirty |= RenderDirtyMask.Transform | RenderDirtyMask.WorldBounds;
}
}
public RenderSceneDigest BuildDigest(RenderSceneDigestBuffer reuse)
{
ArgumentNullException.ThrowIfNull(reuse);
EnsureAvailable();
List<RenderProjectionRecord> 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<RenderProjectionRecord> 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<TTag>(
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<ProjectionIdentity>(entry.Entity).Id,
entry.ProjectionClass,
_world.Get<RenderOwnerIncarnation>(entry.Entity),
_world.Get<RenderTransform>(entry.Entity),
_world.Get<PreviousRenderTransform>(entry.Entity),
_world.Get<RenderMeshSet>(entry.Entity),
_world.Get<RenderMaterialVariant>(entry.Entity),
_world.Get<RenderSpatialResidency>(entry.Entity),
_world.Get<RenderWorldBounds>(entry.Entity),
_world.Get<RenderProjectionFlags>(entry.Entity),
_world.Get<RenderDegradeState>(entry.Entity),
_world.Get<RenderSortKey>(entry.Entity),
_world.Get<RenderDirtyMask>(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<RenderDirtyMask>(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<RenderProjectionRecord>
{
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);
}
}
}

View file

@ -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<DynamicProjectionUpdate> updates)
{
Generation = generation;
Updates = updates;
}
public RenderSceneGeneration Generation { get; }
public ReadOnlySpan<DynamicProjectionUpdate> 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<RenderProjectionRecord> 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<RenderProjectionRecord> 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<RenderProjectionRecord> destination) =>
Source.CopyTo(Generation, null, destination);
public int CopyTo(
RenderProjectionClass projectionClass,
Span<RenderProjectionRecord> 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<RenderProjectionDelta> deltas);
void SynchronizeDynamicSources(in DynamicProjectionSyncInput input);
RenderSceneDigest BuildDigest(RenderSceneDigestBuffer reuse);
RenderSceneQuery OpenQuery();
void Clear(RenderSceneGeneration replacementGeneration);
}

View file

@ -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<InvalidOperationException>(() => _ = borrowed.Counts);
Assert.Throws<ArgumentOutOfRangeException>(
() => 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<InvalidOperationException>(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);
}

View file

@ -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<string>();
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<MethodBase>()
.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.");
}
}