feat(headless): share immutable gameplay content

This commit is contained in:
Erik 2026-07-27 09:00:48 +02:00
parent 12b500d383
commit 9569dadb57
25 changed files with 1031 additions and 429 deletions

View file

@ -0,0 +1,245 @@
using AcDream.Content.Pak;
using AcDream.Core.Physics;
namespace AcDream.Content;
public readonly record struct SharedPreparedCollisionCacheSnapshot(
int Count,
int Capacity,
long Hits,
long Misses,
bool IsDisposed)
{
public bool IsBounded => Count >= 0 && Count <= Capacity;
}
/// <summary>
/// Process-scoped bounded cache over immutable prepared collision payloads.
/// The wrapped package remains owned by the caller; disposing this cache only
/// retires its decoded references. Sessions retain separate
/// <see cref="PhysicsDataCache"/> instances and share only these immutable
/// values.
/// </summary>
public sealed class SharedPreparedCollisionCache
: IPreparedCollisionSource
{
public const int DefaultCapacity = 4096;
private readonly object _gate = new();
private readonly IPreparedCollisionSource _source;
private readonly int _capacity;
private readonly Dictionary<CacheKey, LinkedListNode<CacheEntry>>
_entries = new();
private readonly LinkedList<CacheEntry> _lru = new();
private long _probes;
private long _reads;
private long _loaded;
private long _missing;
private long _corrupt;
private long _hits;
private long _misses;
private bool _disposed;
public SharedPreparedCollisionCache(
IPreparedCollisionSource source,
int capacity = DefaultCapacity)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_capacity = capacity;
}
public PreparedCollisionSourceStats CollisionStats => new(
Interlocked.Read(ref _probes),
Interlocked.Read(ref _reads),
Interlocked.Read(ref _loaded),
Interlocked.Read(ref _missing),
Interlocked.Read(ref _corrupt));
public SharedPreparedCollisionCacheSnapshot CaptureSnapshot()
{
lock (_gate)
{
return new SharedPreparedCollisionCacheSnapshot(
_entries.Count,
_capacity,
Interlocked.Read(ref _hits),
Interlocked.Read(ref _misses),
_disposed);
}
}
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId)
{
PreparedCollisionTypeContract.Validate(type);
Interlocked.Increment(ref _probes);
lock (_gate)
{
EnsureNotDisposed();
if (_entries.TryGetValue(
new CacheKey(type, sourceFileId),
out LinkedListNode<CacheEntry>? node))
{
Touch(node);
return node.Value.Status switch
{
PreparedAssetReadStatus.Loaded =>
PreparedAssetPresence.Available,
PreparedAssetReadStatus.Corrupt =>
PreparedAssetPresence.Corrupt,
_ => PreparedAssetPresence.Missing,
};
}
return _source.ProbeCollision(type, sourceFileId);
}
}
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.GfxObjCollision,
sourceFileId,
token => _source.ReadGfxObjCollision(sourceFileId, token),
cancellationToken);
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.SetupCollision,
sourceFileId,
token => _source.ReadSetupCollision(sourceFileId, token),
cancellationToken);
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.CellStructureCollision,
sourceFileId,
token => _source.ReadCellStructureCollision(
sourceFileId,
token),
cancellationToken);
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Read(
PakAssetType.EnvCellTopology,
sourceFileId,
token => _source.ReadEnvCellTopology(sourceFileId, token),
cancellationToken);
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_entries.Clear();
_lru.Clear();
_disposed = true;
}
}
private PreparedCollisionReadResult<T> Read<T>(
PakAssetType type,
uint sourceFileId,
Func<CancellationToken, PreparedCollisionReadResult<T>> loader,
CancellationToken cancellationToken)
where T : class
{
cancellationToken.ThrowIfCancellationRequested();
Interlocked.Increment(ref _reads);
lock (_gate)
{
EnsureNotDisposed();
var key = new CacheKey(type, sourceFileId);
if (_entries.TryGetValue(
key,
out LinkedListNode<CacheEntry>? node))
{
Touch(node);
Interlocked.Increment(ref _hits);
return CountAndConvert<T>(node.Value);
}
Interlocked.Increment(ref _misses);
PreparedCollisionReadResult<T> loaded =
loader(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var entry = new CacheEntry(
key,
loaded.Status,
loaded.Data);
LinkedListNode<CacheEntry> added = _lru.AddFirst(entry);
_entries.Add(key, added);
Trim();
return CountAndConvert<T>(entry);
}
}
private PreparedCollisionReadResult<T> CountAndConvert<T>(
CacheEntry entry)
where T : class
{
switch (entry.Status)
{
case PreparedAssetReadStatus.Loaded
when entry.Data is T data:
Interlocked.Increment(ref _loaded);
return PreparedCollisionReadResult<T>.Loaded(data);
case PreparedAssetReadStatus.Missing:
Interlocked.Increment(ref _missing);
return PreparedCollisionReadResult<T>.Missing;
case PreparedAssetReadStatus.Corrupt:
Interlocked.Increment(ref _corrupt);
return PreparedCollisionReadResult<T>.Corrupt;
default:
throw new InvalidDataException(
$"Prepared collision cache entry {entry.Key} has an invalid payload.");
}
}
private void Touch(LinkedListNode<CacheEntry> node)
{
if (ReferenceEquals(_lru.First, node))
return;
_lru.Remove(node);
_lru.AddFirst(node);
}
private void Trim()
{
while (_entries.Count > _capacity)
{
LinkedListNode<CacheEntry> tail =
_lru.Last
?? throw new InvalidOperationException(
"Prepared collision LRU lost its terminal entry.");
_entries.Remove(tail.Value.Key);
_lru.RemoveLast();
}
}
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private readonly record struct CacheKey(
PakAssetType Type,
uint SourceFileId);
private sealed record CacheEntry(
CacheKey Key,
PreparedAssetReadStatus Status,
object? Data);
}