feat(headless): hydrate isolated collision worlds
This commit is contained in:
parent
9569dadb57
commit
b6547ff38c
20 changed files with 1960 additions and 101 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
433
src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
Normal file
433
src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
49
src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
Normal file
49
src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue