diff --git a/src/AcDream.Content/DatCollectionAdapter.cs b/src/AcDream.Content/DatCollectionAdapter.cs
index e77bc1a3..cad4551d 100644
--- a/src/AcDream.Content/DatCollectionAdapter.cs
+++ b/src/AcDream.Content/DatCollectionAdapter.cs
@@ -117,8 +117,10 @@ public sealed class DatCollectionAdapter : IDatReaderWriter {
public int LanguageIteration => _language.Iteration;
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) {
- // Route to cell db (the only region we expose)
- return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead);
+ // Route through the wrapper's serialized raw-read path. The process
+ // content owner is shared by headless sessions, while DatDatabase's
+ // seek/read cursor is not a per-caller resource.
+ return _cell.TryGetFileBytes(fileId, ref bytes, out bytesRead);
}
///
diff --git a/src/AcDream.App/RuntimeDatCollectionFactory.cs b/src/AcDream.Content/RuntimeDatCollectionFactory.cs
similarity index 89%
rename from src/AcDream.App/RuntimeDatCollectionFactory.cs
rename to src/AcDream.Content/RuntimeDatCollectionFactory.cs
index 5890b9e2..dd3a7c7b 100644
--- a/src/AcDream.App/RuntimeDatCollectionFactory.cs
+++ b/src/AcDream.Content/RuntimeDatCollectionFactory.cs
@@ -1,14 +1,14 @@
using DatReaderWriter;
using DatReaderWriter.Options;
-using AcDream.Content;
using DatReaderWriter.Lib.IO;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
-namespace AcDream.App;
+namespace AcDream.Content;
///
-/// Opens the read-only DAT collection owned for the lifetime of an App process.
+/// Opens the read-only DAT collection owned for the lifetime of one client
+/// process.
///
///
/// DatReaderWriter's default retains
@@ -18,12 +18,12 @@ namespace AcDream.App;
/// B-tree lookup cache, but read file payloads on demand and let the owning
/// runtime caches decide their residency.
///
-internal static class RuntimeDatCollectionFactory
+public static class RuntimeDatCollectionFactory
{
- internal static IDatReaderWriter OpenReadOnly(string datDirectory) =>
+ public static IDatReaderWriter OpenReadOnly(string datDirectory) =>
new RuntimeDatCollection(new DatCollection(CreateReadOnlyOptions(datDirectory)));
- internal static DatCollectionOptions CreateReadOnlyOptions(string datDirectory)
+ public static DatCollectionOptions CreateReadOnlyOptions(string datDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(datDirectory);
@@ -41,7 +41,7 @@ internal static class RuntimeDatCollectionFactory
/// Owns the runtime's one raw DAT handle set and its one shared bounded typed
/// object facade. Disposing this owner closes both layers exactly once.
///
-internal sealed class RuntimeDatCollection : IDatReaderWriter
+public sealed class RuntimeDatCollection : IDatReaderWriter
{
private readonly DatCollection _raw;
private readonly DatCollectionAdapter _bounded;
@@ -57,7 +57,7 @@ internal sealed class RuntimeDatCollection : IDatReaderWriter
public string SourceDirectory => _bounded.SourceDirectory;
/// Aggregate DAT-object cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).
- internal CacheStats ObjectCacheStats => _bounded.ObjectCacheStats;
+ public CacheStats ObjectCacheStats => _bounded.ObjectCacheStats;
public IDatDatabase Portal => _bounded.Portal;
public IDatDatabase Cell => _bounded.Cell;
diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
index 752177f7..e05cb864 100644
--- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
+++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
@@ -16,6 +16,17 @@ internal sealed class HeadlessConfiguration
internal sealed class HeadlessProcessSettings
{
public HeadlessPathOverrides Paths { get; init; } = new();
+
+ public HeadlessContentDescriptor? Content { get; init; }
+}
+
+internal sealed class HeadlessContentDescriptor
+{
+ [JsonRequired]
+ public string DatDirectory { get; init; } = string.Empty;
+
+ [JsonRequired]
+ public string PreparedAssetPath { get; init; } = string.Empty;
}
internal sealed class HeadlessSessionDescriptor
diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
index 20582f13..92b1e978 100644
--- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
+++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
@@ -52,6 +52,8 @@ internal static class HeadlessConfigurationLoader
"sessions must be an array.");
}
+ ValidateContent(configuration.Process?.Content);
+
var sessionIds = new HashSet(StringComparer.Ordinal);
var credentialReferences = new HashSet(
StringComparer.Ordinal);
@@ -83,6 +85,18 @@ internal static class HeadlessConfigurationLoader
return configuration;
}
+ private static void ValidateContent(HeadlessContentDescriptor? content)
+ {
+ if (content is null)
+ return;
+ if (string.IsNullOrWhiteSpace(content.DatDirectory)
+ || string.IsNullOrWhiteSpace(content.PreparedAssetPath))
+ {
+ throw new HeadlessConfigurationException(
+ "process.content requires non-empty datDirectory and preparedAssetPath.");
+ }
+ }
+
private static void ValidateSession(HeadlessSessionDescriptor session)
{
if (session.Endpoint is null
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
new file mode 100644
index 00000000..5c839845
--- /dev/null
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
@@ -0,0 +1,227 @@
+using AcDream.Content;
+using AcDream.Headless.Configuration;
+
+namespace AcDream.Headless.Hosting;
+
+internal readonly record struct HeadlessProcessContentSnapshot(
+ int LeaseCount,
+ bool IsDisposeRequested,
+ bool IsDisposed,
+ long MappedVirtualBytes)
+{
+ internal bool IsConverged =>
+ IsDisposed
+ && LeaseCount == 0
+ && MappedVirtualBytes == 0L;
+}
+
+internal interface IHeadlessProcessContentFactory
+{
+ (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
+ HeadlessContentDescriptor descriptor,
+ Action diagnostic);
+}
+
+internal sealed class ProductionHeadlessProcessContentFactory
+ : IHeadlessProcessContentFactory
+{
+ public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
+ HeadlessContentDescriptor descriptor,
+ Action diagnostic)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(diagnostic);
+
+ string datDirectory = Path.GetFullPath(descriptor.DatDirectory);
+ string preparedAssetPath =
+ Path.GetFullPath(descriptor.PreparedAssetPath);
+ IDatReaderWriter? dats = null;
+ try
+ {
+ dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
+ var prepared = new PakPreparedAssetSource(
+ preparedAssetPath,
+ dats,
+ diagnostic);
+ return (dats, prepared);
+ }
+ catch
+ {
+ dats?.Dispose();
+ throw;
+ }
+ }
+}
+
+///
+/// Process lifetime root for immutable/read-only content shared by all
+/// headless sessions. A lease carries no mutable session state. Disposal is
+/// requested once and closes the package/DAT handles only after the final
+/// session releases its lease.
+///
+internal sealed class HeadlessProcessContentOwner : IDisposable
+{
+ private readonly object _gate = new();
+ private IDatReaderWriter? _dats;
+ private IPreparedAssetSource? _prepared;
+ private int _leaseCount;
+ private bool _disposeRequested;
+ private bool _disposed;
+
+ internal HeadlessProcessContentOwner(
+ HeadlessContentDescriptor descriptor,
+ Action diagnostic,
+ IHeadlessProcessContentFactory? factory = null)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(diagnostic);
+ (IDatReaderWriter dats, IPreparedAssetSource prepared) =
+ (factory ?? new ProductionHeadlessProcessContentFactory())
+ .Open(descriptor, diagnostic);
+ _dats = dats
+ ?? throw new InvalidOperationException(
+ "The headless content factory returned no DAT owner.");
+ _prepared = prepared
+ ?? throw new InvalidOperationException(
+ "The headless content factory returned no prepared source.");
+ if (prepared is not IPreparedCollisionSource)
+ {
+ try
+ {
+ prepared.Dispose();
+ }
+ finally
+ {
+ dats.Dispose();
+ }
+ _prepared = null;
+ _dats = null;
+ throw new NotSupportedException(
+ "Headless production content must expose prepared collision.");
+ }
+ }
+
+ internal HeadlessProcessContentLease AcquireLease(string sessionId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(
+ _disposeRequested || _disposed,
+ this);
+ checked
+ {
+ _leaseCount++;
+ }
+ return new HeadlessProcessContentLease(
+ this,
+ sessionId,
+ _dats!,
+ _prepared!);
+ }
+ }
+
+ internal HeadlessProcessContentSnapshot CaptureSnapshot()
+ {
+ lock (_gate)
+ {
+ return new HeadlessProcessContentSnapshot(
+ _leaseCount,
+ _disposeRequested,
+ _disposed,
+ _prepared?.MappedVirtualBytes ?? 0L);
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposeRequested = true;
+ if (_leaseCount != 0)
+ return;
+ DrainResources();
+ }
+ }
+
+ private void Release()
+ {
+ lock (_gate)
+ {
+ if (_leaseCount <= 0)
+ {
+ throw new InvalidOperationException(
+ "Headless content lease count underflow.");
+ }
+ _leaseCount--;
+ if (_leaseCount == 0 && _disposeRequested)
+ DrainResources();
+ }
+ }
+
+ private void DrainResources()
+ {
+ if (_prepared is not null)
+ {
+ _prepared.Dispose();
+ _prepared = null;
+ }
+ if (_dats is not null)
+ {
+ _dats.Dispose();
+ _dats = null;
+ }
+ _disposed = true;
+ }
+
+ internal sealed class HeadlessProcessContentLease : IDisposable
+ {
+ private HeadlessProcessContentOwner? _owner;
+ private readonly IDatReaderWriter _dats;
+ private readonly IPreparedAssetSource _prepared;
+
+ internal HeadlessProcessContentLease(
+ HeadlessProcessContentOwner owner,
+ string sessionId,
+ IDatReaderWriter dats,
+ IPreparedAssetSource prepared)
+ {
+ _owner = owner;
+ SessionId = sessionId;
+ _dats = dats;
+ _prepared = prepared;
+ }
+
+ internal string SessionId { get; }
+
+ internal IDatReaderWriter Dats
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_owner is null, this);
+ return _dats;
+ }
+ }
+
+ internal IPreparedAssetSource PreparedAssets
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_owner is null, this);
+ return _prepared;
+ }
+ }
+
+ internal IPreparedCollisionSource PreparedCollision =>
+ (IPreparedCollisionSource)PreparedAssets;
+
+ public void Dispose()
+ {
+ HeadlessProcessContentOwner? owner =
+ Interlocked.Exchange(ref _owner, null);
+ owner?.Release();
+ }
+ }
+}
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
index 4edc4910..926ba949 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
@@ -12,6 +12,7 @@ internal sealed class HeadlessProcessHost : IDisposable
private readonly HeadlessSessionHost[] _sessions;
private readonly HeadlessProcessScheduler _scheduler;
private readonly HeadlessDiagnosticWriter _diagnostics;
+ private readonly HeadlessProcessContentOwner? _content;
private int _disposeIndex;
private bool _disposed;
@@ -21,7 +22,8 @@ internal sealed class HeadlessProcessHost : IDisposable
TextReader standardInput,
TextWriter diagnostics,
ILiveSessionOperations? sessionOperations = null,
- TimeProvider? timeProvider = null)
+ TimeProvider? timeProvider = null,
+ IHeadlessProcessContentFactory? contentFactory = null)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths);
@@ -39,8 +41,19 @@ internal sealed class HeadlessProcessHost : IDisposable
paths.ConfigDirectory);
var sessions = new List(
configuration.Sessions.Count);
+ HeadlessProcessContentOwner? content = null;
try
{
+ if (configuration.Process?.Content is { } contentDescriptor)
+ {
+ content = new HeadlessProcessContentOwner(
+ contentDescriptor,
+ message => _diagnostics.Message(
+ "process-content",
+ message),
+ contentFactory);
+ }
+
foreach (HeadlessSessionDescriptor? candidate
in configuration.Sessions)
{
@@ -51,6 +64,8 @@ internal sealed class HeadlessProcessHost : IDisposable
credentials.Resolve(
descriptor.Id,
descriptor.Credential);
+ HeadlessProcessContentOwner.HeadlessProcessContentLease?
+ contentLease = content?.AcquireLease(descriptor.Id);
try
{
sessions.Add(new HeadlessSessionHost(
@@ -58,10 +73,12 @@ internal sealed class HeadlessProcessHost : IDisposable
secret,
_diagnostics,
sessionOperations,
- timeProvider));
+ timeProvider,
+ contentLease: contentLease));
}
catch
{
+ contentLease?.Dispose();
secret.Dispose();
throw;
}
@@ -71,12 +88,14 @@ internal sealed class HeadlessProcessHost : IDisposable
_scheduler = new HeadlessProcessScheduler(
_sessions,
timeProvider);
+ _content = content;
_disposeIndex = _sessions.Length - 1;
}
catch
{
for (int index = sessions.Count - 1; index >= 0; index--)
sessions[index].Dispose();
+ content?.Dispose();
throw;
}
}
@@ -88,6 +107,8 @@ internal sealed class HeadlessProcessHost : IDisposable
internal IReadOnlyList Sessions => _sessions;
internal HeadlessSchedulerSnapshot Scheduler =>
_scheduler.CaptureSnapshot();
+ internal HeadlessProcessContentSnapshot? Content =>
+ _content?.CaptureSnapshot();
internal async Task RunAsync(
CancellationToken cancellationToken)
@@ -141,6 +162,7 @@ internal sealed class HeadlessProcessHost : IDisposable
_sessions[_disposeIndex].Dispose();
_disposeIndex--;
}
+ _content?.Dispose();
_disposed = true;
}
}
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 72ba48a1..d6bf13bb 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -117,6 +117,8 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IDisposable _policySubscription;
private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
+ private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
+ _contentLease;
private int _disposeStage;
private long _reconnectDeadline;
private bool _reconnectPending;
@@ -129,7 +131,9 @@ internal sealed class HeadlessSessionHost : IDisposable
HeadlessDiagnosticWriter diagnostics,
ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null,
- TimeSpan? reconnectQuiescence = null)
+ TimeSpan? reconnectQuiescence = null,
+ HeadlessProcessContentOwner.HeadlessProcessContentLease?
+ contentLease = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
@@ -217,6 +221,7 @@ internal sealed class HeadlessSessionHost : IDisposable
liveSession),
new HeadlessMovementInputSource(
runtime.MovementOwner));
+ _contentLease = contentLease;
bridge.Bind(this);
hostLease = runtime.AcquireHostLease(
@@ -238,6 +243,7 @@ internal sealed class HeadlessSessionHost : IDisposable
policySubscription?.Dispose();
policy?.Dispose();
hostLease?.Dispose();
+ contentLease?.Dispose();
credential.Dispose();
runtimeRef?.Dispose();
throw;
@@ -251,6 +257,8 @@ internal sealed class HeadlessSessionHost : IDisposable
string.Empty;
internal bool IsPolicyComplete => _policy.IsComplete;
internal bool IsReconnectPending => _reconnectPending;
+ internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
+ Content => _contentLease;
internal long ReconnectDeadline => _reconnectPending
? _reconnectDeadline
: throw new InvalidOperationException(
@@ -354,6 +362,10 @@ internal sealed class HeadlessSessionHost : IDisposable
_disposeStage++;
break;
case 7:
+ _contentLease?.Dispose();
+ _disposeStage++;
+ break;
+ case 8:
_diagnostics.Message(
_descriptor.Id,
"disposed",
diff --git a/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs b/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs
index 4b9b2d42..22da595f 100644
--- a/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs
@@ -1,4 +1,5 @@
using DatReaderWriter;
+using AcDream.Content;
using System.Reflection;
using System.Text.RegularExpressions;
@@ -7,14 +8,20 @@ namespace AcDream.App.Tests;
public sealed class RuntimeDatAccessArchitectureTests
{
[Fact]
- public void ProductionAppTypes_DoNotStoreOrAcceptRawDatCollection()
+ public void ProductionTypes_KeepRawDatCollectionInsideContentOwnerSeam()
{
Type rawType = typeof(DatReaderWriter.DatCollection);
Type ownerType = typeof(RuntimeDatCollection);
- Type[] appTypes = typeof(RuntimeDatCollectionFactory).Assembly.GetTypes();
+ Type adapterType = typeof(DatCollectionAdapter);
+ Type[] productionTypes =
+ typeof(AcDream.App.Rendering.GameWindow).Assembly.GetTypes()
+ .Concat(typeof(RuntimeDatCollectionFactory).Assembly.GetTypes())
+ .Distinct()
+ .ToArray();
var violations = new List();
- foreach (Type type in appTypes.Where(type => type != ownerType))
+ foreach (Type type in productionTypes.Where(
+ type => type != ownerType && type != adapterType))
{
foreach (FieldInfo field in type.GetFields(
BindingFlags.Instance | BindingFlags.Static |
@@ -54,9 +61,16 @@ public sealed class RuntimeDatAccessArchitectureTests
{
string root = FindRepoRoot();
string appRoot = Path.Combine(root, "src", "AcDream.App");
+ string contentRoot = Path.Combine(root, "src", "AcDream.Content");
string ownerPath = Path.GetFullPath(
- Path.Combine(appRoot, "RuntimeDatCollectionFactory.cs"));
- string[] sources = Directory.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories);
+ Path.Combine(contentRoot, "RuntimeDatCollectionFactory.cs"));
+ string[] sources = Directory
+ .GetFiles(appRoot, "*.cs", SearchOption.AllDirectories)
+ .Concat(Directory.GetFiles(
+ contentRoot,
+ "*.cs",
+ SearchOption.AllDirectories))
+ .ToArray();
string[] rawCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollection\s*\(");
string[] adapterCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollectionAdapter\s*\(");
diff --git a/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs b/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs
index 9b75b529..f20d2120 100644
--- a/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs
+++ b/tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs
@@ -1,4 +1,5 @@
using DatReaderWriter.Options;
+using AcDream.Content;
namespace AcDream.App.Tests;
diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
index 10ada977..7667e25c 100644
--- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
@@ -84,6 +84,46 @@ public sealed class HeadlessEntryPointTests
Assert.Equal(string.Empty, error.ToString());
}
+ [Fact]
+ public void ValidateAcceptsCompleteProcessContentDescriptor()
+ {
+ using var file = TemporaryConfiguration.Create(
+ """{"version":1,"process":{"content":{"datDirectory":"C:\\AC","preparedAssetPath":"C:\\AC\\acdream.pak"}},"sessions":[]}""");
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ ["validate", "--config", file.Path],
+ output,
+ error);
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("0 session(s)", output.ToString());
+ Assert.Equal(string.Empty, error.ToString());
+ }
+
+ [Theory]
+ [InlineData("""{"version":1,"process":{"content":{"datDirectory":"","preparedAssetPath":"package.pak"}},"sessions":[]}""")]
+ [InlineData("""{"version":1,"process":{"content":{"datDirectory":"dats","preparedAssetPath":""}},"sessions":[]}""")]
+ public void ValidateRejectsIncompleteProcessContentDescriptor(string json)
+ {
+ using var file = TemporaryConfiguration.Create(json);
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ ["validate", "--config", file.Path],
+ output,
+ error);
+
+ Assert.Equal(3, exitCode);
+ Assert.Contains(
+ "process.content",
+ error.ToString(),
+ StringComparison.Ordinal);
+ Assert.Equal(string.Empty, output.ToString());
+ }
+
[Theory]
[InlineData("duplicate-id")]
[InlineData("duplicate-credential")]
diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
new file mode 100644
index 00000000..d776787a
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
@@ -0,0 +1,256 @@
+using System.Reflection;
+using AcDream.Content;
+using AcDream.Headless.Configuration;
+using AcDream.Headless.Hosting;
+using AcDream.Headless.Platform;
+
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessProcessContentOwnerTests
+{
+ [Fact]
+ public void DisposeRequestRetiresResourcesAfterExactFinalLease()
+ {
+ var factory = new FixtureContentFactory();
+ var owner = new HeadlessProcessContentOwner(
+ ContentDescriptor(),
+ _ => { },
+ factory);
+ using var first = owner.AcquireLease("first");
+ using var second = owner.AcquireLease("second");
+
+ Assert.Equal(1, factory.OpenCount);
+ Assert.Equal(
+ new HeadlessProcessContentSnapshot(
+ LeaseCount: 2,
+ IsDisposeRequested: false,
+ IsDisposed: false,
+ MappedVirtualBytes: 4096L),
+ owner.CaptureSnapshot());
+
+ owner.Dispose();
+
+ Assert.True(owner.CaptureSnapshot().IsDisposeRequested);
+ Assert.False(owner.CaptureSnapshot().IsDisposed);
+ Assert.Throws(
+ () => owner.AcquireLease("late"));
+
+ first.Dispose();
+ Assert.Equal(1, owner.CaptureSnapshot().LeaseCount);
+ Assert.Equal(0, factory.Dats.DisposeSuccessCount);
+ Assert.Equal(0, factory.Prepared.DisposeSuccessCount);
+
+ second.Dispose();
+
+ Assert.True(owner.CaptureSnapshot().IsConverged);
+ Assert.Equal(1, factory.Dats.DisposeSuccessCount);
+ Assert.Equal(1, factory.Prepared.DisposeSuccessCount);
+
+ owner.Dispose();
+ first.Dispose();
+ second.Dispose();
+ Assert.Equal(1, factory.Dats.DisposeSuccessCount);
+ Assert.Equal(1, factory.Prepared.DisposeSuccessCount);
+ }
+
+ [Fact]
+ public void FailedFinalDrainRetainsRetryableOwnerSuffix()
+ {
+ var factory = new FixtureContentFactory();
+ var owner = new HeadlessProcessContentOwner(
+ ContentDescriptor(),
+ _ => { },
+ factory);
+ using var lease = owner.AcquireLease("retry");
+ owner.Dispose();
+ factory.Prepared.FailNextDispose = true;
+
+ Assert.Throws(lease.Dispose);
+
+ HeadlessProcessContentSnapshot failed = owner.CaptureSnapshot();
+ Assert.Equal(0, failed.LeaseCount);
+ Assert.True(failed.IsDisposeRequested);
+ Assert.False(failed.IsDisposed);
+ Assert.Equal(0, factory.Dats.DisposeSuccessCount);
+
+ owner.Dispose();
+
+ Assert.True(owner.CaptureSnapshot().IsConverged);
+ Assert.Equal(1, factory.Prepared.DisposeSuccessCount);
+ Assert.Equal(1, factory.Dats.DisposeSuccessCount);
+ }
+
+ [Fact]
+ public void FiveSessionsShareContentButNotMutableRuntimeOwners()
+ {
+ const int sessionCount = 5;
+ var factory = new FixtureContentFactory();
+ var configuration = new HeadlessConfiguration
+ {
+ Version = 1,
+ Process = new HeadlessProcessSettings
+ {
+ Content = ContentDescriptor(),
+ },
+ Sessions = Enumerable.Range(0, sessionCount)
+ .Select(index => Session(index))
+ .Cast()
+ .ToList(),
+ };
+ using var input = new StringReader(string.Concat(
+ Enumerable.Repeat(
+ "fixture-password" + Environment.NewLine,
+ sessionCount)));
+ using var host = new HeadlessProcessHost(
+ configuration,
+ HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
+ input,
+ TextWriter.Null,
+ contentFactory: factory);
+
+ Assert.Equal(1, factory.OpenCount);
+ Assert.Equal(sessionCount, host.Sessions.Count);
+ Assert.Equal(sessionCount, host.Content?.LeaseCount);
+ Assert.All(
+ host.Sessions,
+ session =>
+ {
+ Assert.Same(
+ factory.DatsResource,
+ session.Content?.Dats);
+ Assert.Same(
+ factory.PreparedResource,
+ session.Content?.PreparedAssets);
+ });
+ Assert.Equal(
+ sessionCount,
+ host.Sessions
+ .Select(static session => session.Runtime)
+ .Distinct(ReferenceEqualityComparer.Instance)
+ .Count());
+ Assert.Equal(
+ sessionCount,
+ host.Sessions
+ .Select(static session =>
+ session.Runtime.EntityObjects.Physics.Engine)
+ .Distinct(ReferenceEqualityComparer.Instance)
+ .Count());
+ Assert.Equal(
+ sessionCount,
+ host.Sessions
+ .Select(static session =>
+ session.Runtime.EntityObjects.Physics.DataCache)
+ .Distinct(ReferenceEqualityComparer.Instance)
+ .Count());
+
+ HeadlessSessionHost[] sessions = host.Sessions.ToArray();
+ host.Dispose();
+
+ Assert.True(host.Content?.IsConverged);
+ Assert.All(
+ sessions,
+ static session =>
+ Assert.True(
+ session.Runtime.CaptureOwnership().IsConverged));
+ Assert.Equal(1, factory.Prepared.DisposeSuccessCount);
+ Assert.Equal(1, factory.Dats.DisposeSuccessCount);
+ }
+
+ private static HeadlessContentDescriptor ContentDescriptor() => new()
+ {
+ DatDirectory = "fixture-dats",
+ PreparedAssetPath = "fixture.pak",
+ };
+
+ private static HeadlessSessionDescriptor Session(int index) => new()
+ {
+ Id = $"bot-{index}",
+ Endpoint = new HeadlessEndpointDescriptor
+ {
+ Host = "127.0.0.1",
+ Port = 9000,
+ },
+ Account = $"account-{index}",
+ Character = new HeadlessCharacterSelector
+ {
+ Index = 0,
+ },
+ Policy = new HeadlessBotPolicyDescriptor
+ {
+ Id = "idle",
+ },
+ Credential = new HeadlessCredentialReference
+ {
+ Provider = HeadlessCredentialProviderKind.StandardInput,
+ Reference = $"stdin-{index}",
+ },
+ };
+
+ private sealed class FixtureContentFactory
+ : IHeadlessProcessContentFactory
+ {
+ internal FixtureContentFactory()
+ {
+ DatsResource =
+ DispatchProxy.Create();
+ PreparedResource =
+ DispatchProxy.Create();
+ Dats = TestResourceProxy.For(DatsResource);
+ Prepared = TestResourceProxy.For(PreparedResource);
+ Prepared.MappedVirtualBytes = 4096L;
+ }
+
+ internal int OpenCount { get; private set; }
+ internal IDatReaderWriter DatsResource { get; }
+ internal ITestPreparedSource PreparedResource { get; }
+ internal TestResourceProxy Dats { get; }
+ internal TestResourceProxy Prepared { get; }
+
+ public (IDatReaderWriter Dats, IPreparedAssetSource Prepared) Open(
+ HeadlessContentDescriptor descriptor,
+ Action diagnostic)
+ {
+ OpenCount++;
+ return (DatsResource, PreparedResource);
+ }
+ }
+}
+
+public interface ITestPreparedSource
+ : IPreparedAssetSource, IPreparedCollisionSource;
+
+public class TestResourceProxy : DispatchProxy
+{
+ public int DisposeSuccessCount { get; private set; }
+ public bool FailNextDispose { get; set; }
+ public long MappedVirtualBytes { get; set; }
+
+ public static TestResourceProxy For(T resource)
+ where T : class =>
+ (TestResourceProxy)(object)resource;
+
+ protected override object? Invoke(
+ MethodInfo? targetMethod,
+ object?[]? args)
+ {
+ ArgumentNullException.ThrowIfNull(targetMethod);
+ if (targetMethod.Name == nameof(IDisposable.Dispose))
+ {
+ if (FailNextDispose)
+ {
+ FailNextDispose = false;
+ throw new IOException("fixture disposal failure");
+ }
+ DisposeSuccessCount++;
+ MappedVirtualBytes = 0L;
+ return null;
+ }
+ if (targetMethod.Name == "get_MappedVirtualBytes")
+ return MappedVirtualBytes;
+
+ Type returnType = targetMethod.ReturnType;
+ return returnType == typeof(void) || !returnType.IsValueType
+ ? null
+ : Activator.CreateInstance(returnType);
+ }
+}