feat(headless): hydrate isolated collision worlds

This commit is contained in:
Erik 2026-07-27 09:25:58 +02:00
parent 9569dadb57
commit b6547ff38c
20 changed files with 1960 additions and 101 deletions

View file

@ -1,5 +1,7 @@
using System.Collections.Immutable;
using AcDream.Content;
using AcDream.Headless.Configuration;
using DatReaderWriter.DBObjs;
namespace AcDream.Headless.Hosting;
@ -25,7 +27,8 @@ internal interface IHeadlessProcessContentFactory
internal sealed record HeadlessOpenedProcessContent(
IDatReaderWriter Dats,
IPreparedAssetSource Prepared,
MagicCatalog Magic);
MagicCatalog Magic,
ImmutableArray<float> HeightTable);
internal sealed class ProductionHeadlessProcessContentFactory
: IHeadlessProcessContentFactory
@ -50,10 +53,24 @@ internal sealed class ProductionHeadlessProcessContentFactory
dats,
diagnostic);
MagicCatalog magic = MagicCatalog.Load(dats);
Region region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException(
"Region dat id 0x13000000 is missing.");
float[]? sourceHeightTable =
region.LandDefs.LandHeightTable;
if (sourceHeightTable is null
|| sourceHeightTable.Length < 256)
{
throw new InvalidOperationException(
"Region.LandDefs.LandHeightTable is missing or truncated.");
}
ImmutableArray<float> heightTable =
ImmutableArray.CreateRange(sourceHeightTable);
return new HeadlessOpenedProcessContent(
dats,
prepared,
magic);
magic,
heightTable);
}
catch
{
@ -80,6 +97,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
{
private readonly object _gate = new();
private readonly MagicCatalog _magic;
private readonly ImmutableArray<float> _heightTable;
private IDatReaderWriter? _dats;
private IPreparedAssetSource? _prepared;
private SharedPreparedCollisionCache? _collision;
@ -105,8 +123,13 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
IDatReaderWriter? dats = opened.Dats;
IPreparedAssetSource? prepared = opened.Prepared;
MagicCatalog? magic = opened.Magic;
ImmutableArray<float> heightTable = opened.HeightTable;
bool incomplete =
dats is null || prepared is null || magic is null;
dats is null
|| prepared is null
|| magic is null
|| heightTable.IsDefaultOrEmpty
|| heightTable.Length < 256;
if (incomplete || prepared is not IPreparedCollisionSource)
{
try
@ -127,6 +150,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
_dats = dats!;
_prepared = prepared!;
_magic = magic!;
_heightTable = heightTable;
_collision = new SharedPreparedCollisionCache(
(IPreparedCollisionSource)prepared!);
}
@ -149,7 +173,8 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
_dats!,
_prepared!,
_collision!,
_magic);
_magic,
_heightTable);
}
}
@ -220,6 +245,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
private readonly IPreparedAssetSource _prepared;
private readonly IPreparedCollisionSource _collision;
private readonly MagicCatalog _magic;
private readonly ImmutableArray<float> _heightTable;
internal HeadlessProcessContentLease(
HeadlessProcessContentOwner owner,
@ -227,7 +253,8 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
IDatReaderWriter dats,
IPreparedAssetSource prepared,
IPreparedCollisionSource collision,
MagicCatalog magic)
MagicCatalog magic,
ImmutableArray<float> heightTable)
{
_owner = owner;
SessionId = sessionId;
@ -235,6 +262,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
_prepared = prepared;
_collision = collision;
_magic = magic;
_heightTable = heightTable;
}
internal string SessionId { get; }
@ -275,6 +303,15 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
}
}
internal ImmutableArray<float> HeightTable
{
get
{
ObjectDisposedException.ThrowIf(_owner is null, this);
return _heightTable;
}
}
public void Dispose()
{
HeadlessProcessContentOwner? owner =

View file

@ -34,6 +34,7 @@ internal sealed class HeadlessProcessHost : IDisposable
throw new HeadlessConfigurationException(
"Headless run mode requires at least one session.");
}
HeadlessStaticStateAudit.ValidateProcessIsolation();
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
var credentials = new HeadlessCredentialResolver(
@ -150,7 +151,9 @@ internal sealed class HeadlessProcessHost : IDisposable
return HeadlessExitCode.RuntimeError;
}
return HeadlessExitCode.Success;
return _scheduler.CaptureSnapshot().FaultedSessionCount > 0
? HeadlessExitCode.RuntimeError
: HeadlessExitCode.Success;
}
public void Dispose()

View file

@ -75,9 +75,12 @@ internal sealed class HeadlessProcessScheduler
internal HeadlessSchedulerSnapshot CaptureSnapshot()
{
int activeSessionCount = 0;
int faultedSessionCount = 0;
for (int index = 0; index < _slots.Length; index++)
{
if (!_slots[index].Session.IsPolicyComplete)
if (_slots[index].Session.IsFaulted)
faultedSessionCount++;
else if (!_slots[index].Session.IsPolicyComplete)
activeSessionCount++;
}
@ -87,6 +90,7 @@ internal sealed class HeadlessProcessScheduler
Interlocked.Read(ref _turnCount),
Interlocked.Read(ref _catchUpCollapseCount),
activeSessionCount,
faultedSessionCount,
NextDeadline());
}
@ -121,33 +125,41 @@ internal sealed class HeadlessProcessScheduler
if (session.IsPolicyComplete)
continue;
if (session.IsReconnectPending)
try
{
if (nowTimestamp < session.ReconnectDeadline)
if (session.IsReconnectPending)
{
if (nowTimestamp < session.ReconnectDeadline)
continue;
RuntimeSessionStartResult result =
session.CompletePendingReconnect(nowTimestamp);
if (result.Status
is not RuntimeSessionStartStatus.Connected)
{
throw result.Error
?? new InvalidOperationException(
$"Headless session reconnect completed with {result.Status}.");
}
slot.LastTurnTimestamp = nowTimestamp;
slot.NextDeadline = AddTicks(
nowTimestamp,
_periodTicks);
dispatched = true;
continue;
}
if (nowTimestamp < slot.NextDeadline)
continue;
RuntimeSessionStartResult result =
session.CompletePendingReconnect(nowTimestamp);
if (result.Status
is not RuntimeSessionStartStatus.Connected)
{
throw result.Error
?? new InvalidOperationException(
$"Headless session reconnect completed with {result.Status}.");
}
slot.LastTurnTimestamp = nowTimestamp;
slot.NextDeadline = AddTicks(
nowTimestamp,
_periodTicks);
DispatchSessionDue(slot, nowTimestamp);
dispatched = true;
}
catch (Exception error)
{
session.Quarantine(error);
dispatched = true;
continue;
}
if (nowTimestamp < slot.NextDeadline)
continue;
DispatchSessionDue(slot, nowTimestamp);
dispatched = true;
}
return dispatched;
}
@ -246,4 +258,5 @@ internal readonly record struct HeadlessSchedulerSnapshot(
long TurnCount,
long CatchUpCollapseCount,
int ActiveSessionCount,
int FaultedSessionCount,
long NextDeadline);

View file

@ -124,6 +124,8 @@ internal sealed class HeadlessSessionHost : IDisposable
private bool _reconnectPending;
private ulong _stoppedGeneration;
private string _accountName = string.Empty;
private Exception? _fault;
private bool _faulted;
private bool _disposed;
internal HeadlessSessionHost(
@ -134,7 +136,8 @@ internal sealed class HeadlessSessionHost : IDisposable
TimeProvider? timeProvider = null,
TimeSpan? reconnectQuiescence = null,
HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = null)
contentLease = null,
IHeadlessBotPolicy? policyOverride = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
@ -235,8 +238,8 @@ internal sealed class HeadlessSessionHost : IDisposable
hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}");
policy =
HeadlessBotPolicyFactory.Create(descriptor.Policy.Id);
policy = policyOverride
?? HeadlessBotPolicyFactory.Create(descriptor.Policy.Id);
policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle(
descriptor.Id,
@ -264,7 +267,10 @@ internal sealed class HeadlessSessionHost : IDisposable
internal string SessionId => _descriptor.Id;
internal string ActiveCharacterName { get; private set; } =
string.Empty;
internal bool IsPolicyComplete => _policy.IsComplete;
internal bool IsPolicyComplete =>
_faulted || _policy.IsComplete;
internal bool IsFaulted => _faulted;
internal Exception? Fault => _fault;
internal bool IsReconnectPending => _reconnectPending;
internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
Content => _contentLease;
@ -296,6 +302,42 @@ internal sealed class HeadlessSessionHost : IDisposable
internal RuntimeTeardownAcknowledgement Stop() =>
Commands.Session.Stop(Runtime.Generation);
internal void Quarantine(Exception error)
{
ArgumentNullException.ThrowIfNull(error);
if (_faulted)
return;
_faulted = true;
_fault = error;
_reconnectPending = false;
_reconnectDeadline = 0L;
_diagnostics.Failure(
_descriptor.Id,
"quarantined",
error);
try
{
// A policy may have faulted from an event callback. Detach it
// before Runtime publishes teardown deltas so the quarantined
// observer cannot poison the canonical stop transaction.
_policySubscription.Dispose();
RuntimeTeardownAcknowledgement stopped = Stop();
if (!stopped.IsComplete)
{
_fault = new AggregateException(
error,
stopped.Error
?? new InvalidOperationException(
$"Headless session '{_descriptor.Id}' did not quiesce after a fault."));
}
}
catch (Exception teardownError)
{
_fault = new AggregateException(error, teardownError);
}
}
internal RuntimeSessionStartResult CompletePendingReconnect(
long nowTimestamp)
{
@ -482,13 +524,18 @@ internal sealed class HeadlessSessionHost : IDisposable
private LiveSessionEventRouter CreateEventRoute(
AcDream.Core.Net.WorldSession session)
{
IRuntimeDirectWorldProjection? worldProjection =
_contentLease is { } content
? new HeadlessSessionWorldProjection(Runtime, content)
: null;
var entities = new RuntimeLiveEntitySessionController(
Runtime,
session,
message => _diagnostics.Message(
_descriptor.Id,
message,
Runtime.Generation.Value));
Runtime.Generation.Value),
worldProjection);
return new LiveSessionEventRouter(
session,
entities.CreateSink(),

View file

@ -0,0 +1,433 @@
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
internal interface IHeadlessCollisionNeighborhood
{
void CenterOn(uint fullCellId);
bool IsReady(uint fullCellId);
}
/// <summary>
/// Per-session mutable collision publication over process-shared immutable
/// DAT and pak inputs. Every session retains its own engine, data cache,
/// cell graph, shadow registry, and publication ledger.
/// </summary>
internal sealed class HeadlessCollisionNeighborhood
: IHeadlessCollisionNeighborhood
{
private readonly GameRuntime _runtime;
private readonly HeadlessProcessContentOwner
.HeadlessProcessContentLease _content;
private readonly HashSet<uint> _resident = [];
private uint _centerLandblock;
internal HeadlessCollisionNeighborhood(
GameRuntime runtime,
HeadlessProcessContentOwner.HeadlessProcessContentLease content)
{
_runtime = runtime
?? throw new ArgumentNullException(nameof(runtime));
_content = content
?? throw new ArgumentNullException(nameof(content));
}
public void CenterOn(uint fullCellId)
{
uint center = CanonicalLandblock(fullCellId);
if (center == 0x0000FFFFu)
{
throw new ArgumentOutOfRangeException(
nameof(fullCellId),
"A collision neighborhood requires a real destination cell.");
}
if (_centerLandblock == center
&& _resident.Contains(center)
&& _runtime.EntityObjects.Physics.Engine
.IsLandblockTerrainResident(center))
{
_runtime.EntityObjects.Physics.Engine
.UpdatePlayerCurrCell(fullCellId);
return;
}
RetireAll();
int centerX = (int)((center >> 24) & 0xFFu);
int centerY = (int)((center >> 16) & 0xFFu);
try
{
PublishOne(
center,
Vector3.Zero,
fullCellId,
required: true);
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
if (dx == 0 && dy == 0)
continue;
int landblockX = centerX + dx;
int landblockY = centerY + dy;
if ((uint)landblockX > byte.MaxValue
|| (uint)landblockY > byte.MaxValue)
{
continue;
}
uint landblockId =
((uint)landblockX << 24)
| ((uint)landblockY << 16)
| 0xFFFFu;
PublishOne(
landblockId,
new Vector3(dx * 192f, dy * 192f, 0f),
fullCellId,
required: false);
}
}
_centerLandblock = center;
_runtime.EntityObjects.Physics.Engine
.UpdatePlayerCurrCell(fullCellId);
}
catch
{
RetireAll();
throw;
}
}
public bool IsReady(uint fullCellId)
{
uint center = CanonicalLandblock(fullCellId);
if (_centerLandblock != center
|| !_resident.Contains(center)
|| !_runtime.EntityObjects.Physics.Engine
.IsLandblockTerrainResident(center))
{
return false;
}
return (fullCellId & 0xFFFFu) < 0x0100u
|| _runtime.EntityObjects.Physics.DataCache
.GetCellStruct(fullCellId) is not null;
}
private void PublishOne(
uint landblockId,
Vector3 origin,
uint currentCellId,
bool required)
{
LoadedLandblock? source =
LandblockLoader.Load(_content.Dats, landblockId);
if (source is null)
{
if (required)
{
throw new InvalidDataException(
$"Required headless landblock 0x{landblockId:X8} is missing.");
}
return;
}
IReadOnlyList<WorldEntity> staticEntities =
LandblockPhysicsContentBuilder.HydrateStaticEntities(
_content.Dats,
source,
origin,
includeVisualBounds: false);
IReadOnlyList<WorldEntity> scenery =
LandblockPhysicsContentBuilder.HydrateProceduralScenery(
_content.Dats,
source,
origin,
_content.HeightTable.AsSpan(),
includeVisualBounds: false);
var entities = new List<WorldEntity>(
staticEntities.Count + scenery.Count);
entities.AddRange(staticEntities);
entities.AddRange(scenery);
PhysicsDatBundle dats =
LandblockPhysicsContentBuilder.BuildDatBundle(
_content.Dats,
landblockId,
entities);
var landblock = new LoadedLandblock(
source.LandblockId,
source.Heightmap,
entities,
dats);
LandblockCollisionBuild collisions =
LandblockPhysicsContentBuilder
.BuildPreparedCollisionClosure(
_content.PreparedCollision,
landblock);
RuntimePhysicsState physics =
_runtime.EntityObjects.Physics;
PhysicsDataCache cache = physics.DataCache;
TerrainSurface terrain =
LandblockPhysicsContentBuilder.BuildTerrainSurface(
landblock,
_content.HeightTable.AsSpan());
var cellSurfaces = new List<CellSurface>();
var portalPlanes = new List<PortalPlane>();
LandblockPhysicsContentBuilder.PublishPreparedCells(
cache,
landblock,
collisions,
origin,
cellSurfaces,
portalPlanes);
LandblockPhysicsContentBuilder.CacheBuildings(
cache,
landblock,
terrain,
origin);
LandblockPhysicsContentBuilder.CachePreparedObjects(
cache,
collisions);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
try
{
physics.AdmitCollisionAssets(
admission,
new RuntimeLandblockCollisionAssets(
landblockId,
terrain,
cellSurfaces,
portalPlanes,
origin.X,
origin.Y,
currentCellId));
_ = LandblockPhysicsContentBuilder
.PublishStaticCollision(
physics.Engine,
cache,
landblock,
collisions,
origin);
_ = physics.CompleteCollisionAdmission(admission);
_resident.Add(CanonicalLandblock(landblockId));
}
catch
{
_ = physics.WithdrawCollision(landblockId);
throw;
}
}
private void RetireAll()
{
if (_resident.Count == 0)
{
_centerLandblock = 0u;
return;
}
uint[] retiring = [.. _resident];
_resident.Clear();
_centerLandblock = 0u;
foreach (uint landblock in retiring)
_ = _runtime.EntityObjects.Physics.WithdrawCollision(landblock);
}
private static uint CanonicalLandblock(uint fullCellId) =>
(fullCellId & 0xFFFF0000u) | 0xFFFFu;
}
/// <summary>
/// No-window projection of canonical Runtime entity and transit state into
/// one local movement controller plus its per-session collision world.
/// </summary>
internal sealed class HeadlessSessionWorldProjection
: IRuntimeDirectWorldProjection
{
private const float DefaultRadius = 0.48f;
private const float DefaultHeight = 1.835f;
private readonly GameRuntime _runtime;
private readonly IHeadlessCollisionNeighborhood _collision;
private readonly IPreparedCollisionSource? _preparedCollision;
internal HeadlessSessionWorldProjection(
GameRuntime runtime,
HeadlessProcessContentOwner.HeadlessProcessContentLease content)
: this(
runtime,
new HeadlessCollisionNeighborhood(runtime, content),
content.PreparedCollision)
{
}
internal HeadlessSessionWorldProjection(
GameRuntime runtime,
IHeadlessCollisionNeighborhood collision,
IPreparedCollisionSource? preparedCollision = null)
{
_runtime = runtime
?? throw new ArgumentNullException(nameof(runtime));
_collision = collision
?? throw new ArgumentNullException(nameof(collision));
_preparedCollision = preparedCollision;
}
public void ProjectSpawn(
RuntimeEntityRecord record,
bool isLocalPlayer)
{
if (isLocalPlayer)
SynchronizeLocalPlayer(record);
}
public void ProjectPosition(
RuntimeEntityRecord record,
bool isLocalPlayer)
{
if (isLocalPlayer)
SynchronizeLocalPlayer(record);
}
public void BeginTeleport()
{
if (_runtime.MovementOwner.Controller is { } controller)
controller.State = PlayerState.PortalSpace;
}
public RuntimeDestinationReadiness PrepareDestination(
long revealGeneration,
RuntimeTeleportDestination destination)
{
_collision.CenterOn(destination.CellId);
if (_runtime.EntityObjects.Entities.TryGetActive(
destination.EntityGuid,
out RuntimeEntityRecord record))
{
SynchronizeLocalPlayer(record);
}
if (_runtime.MovementOwner.Controller is { } controller)
controller.State = PlayerState.InWorld;
bool ready = _collision.IsReady(destination.CellId);
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
return new RuntimeDestinationReadiness(
revealGeneration,
destination.CellId,
indoor,
IsUnhydratable: !ready,
RequiredRenderRadius: indoor ? 0 : 1,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: ready);
}
private void SynchronizeLocalPlayer(RuntimeEntityRecord record)
{
if (record.ServerGuid
!= _runtime.PlayerIdentity.ServerGuid
|| record.Snapshot.Position is not { } position)
{
return;
}
_collision.CenterOn(position.LandblockId);
PlayerMovementController controller =
_runtime.MovementOwner.Controller
?? CreateController(record);
Vector3 wirePosition = new(
position.PositionX,
position.PositionY,
position.PositionZ);
Quaternion orientation = new(
position.RotationX,
position.RotationY,
position.RotationZ,
position.RotationW);
ResolveResult resolved =
_runtime.EntityObjects.Physics.Engine.Resolve(
wirePosition,
position.LandblockId,
Vector3.Zero,
100f);
ResolveResult placement =
_runtime.EntityObjects.Physics.Engine.ResolvePlacement(
resolved.Position,
resolved.CellId,
DefaultRadius,
DefaultHeight,
controller.StepUpHeight,
controller.StepDownHeight,
ObjectInfoState.IsPlayer
| ObjectInfoState.EdgeSlide,
record.LocalEntityId ?? 0u);
if (placement.Ok)
resolved = placement;
controller.LocalEntityId = record.LocalEntityId ?? 0u;
controller.SetPosition(
resolved.Position,
resolved.CellId,
wirePosition);
controller.SetBodyOrientation(orientation);
}
private PlayerMovementController CreateController(
RuntimeEntityRecord record)
{
var controller = new PlayerMovementController(
_runtime.EntityObjects.Physics.Engine,
record.ObjectClock,
PlayerMovementConstructionOptions.From(
_runtime.CharacterOwner.MovementSkills.Snapshot));
controller.ApplyPhysicsState(record.FinalPhysicsState);
controller.LocalEntityId = record.LocalEntityId ?? 0u;
ApplySetupStepHeights(record, controller);
RuntimeMovementSkillProjection.ApplyTo(
_runtime.CharacterOwner.MovementSkills,
controller);
_runtime.MovementOwner.Controller = controller;
return controller;
}
private void ApplySetupStepHeights(
RuntimeEntityRecord record,
PlayerMovementController controller)
{
if (record.Snapshot.SetupTableId is not { } setupId
|| (setupId & 0xFF000000u) != 0x02000000u
|| _preparedCollision is null)
{
return;
}
PreparedCollisionReadResult<FlatSetupCollision> read =
_preparedCollision.ReadSetupCollision(setupId);
if (read.Status != PreparedAssetReadStatus.Loaded
|| read.Data is not { } setup)
{
throw new InvalidDataException(
$"Player Setup collision 0x{setupId:X8} is {read.Status}.");
}
_runtime.EntityObjects.Physics.DataCache.CacheSetup(
setupId,
setup);
controller.StepUpHeight = setup.StepUpHeight > 0f
? setup.StepUpHeight
: 0.4f;
controller.StepDownHeight = setup.StepDownHeight > 0f
? setup.StepDownHeight
: 0.4f;
}
}

View file

@ -0,0 +1,49 @@
using System.Reflection;
using AcDream.Core.Physics;
using AcDream.Headless.Configuration;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Rejects process-global physics probes whose mutable capture cursors and
/// last-hit fields cannot be attributed safely when several Runtime roots
/// share one process. Ordinary diagnostics remain session-labelled through
/// <see cref="Diagnostics.HeadlessDiagnosticWriter"/>.
/// </summary>
internal static class HeadlessStaticStateAudit
{
internal static void ValidateProcessIsolation()
{
var enabled = new List<string>();
foreach (PropertyInfo property in typeof(PhysicsDiagnostics)
.GetProperties(BindingFlags.Public | BindingFlags.Static)
.OrderBy(static property => property.Name, StringComparer.Ordinal))
{
if (property.PropertyType != typeof(bool)
|| property.GetMethod is null
|| (!property.Name.StartsWith(
"Probe",
StringComparison.Ordinal)
&& !property.Name.StartsWith(
"Dump",
StringComparison.Ordinal)))
{
continue;
}
if (property.GetValue(null) is true)
enabled.Add(property.Name);
}
if (PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
enabled.Add(nameof(PhysicsDiagnostics.CollisionShadowSampleEvery));
if (PhysicsResolveCapture.IsEnabled)
enabled.Add(nameof(PhysicsResolveCapture));
if (enabled.Count != 0)
{
throw new HeadlessConfigurationException(
"Multi-session headless mode cannot use process-global "
+ "physics probes. Disable: "
+ string.Join(", ", enabled));
}
}
}