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

@ -69,28 +69,6 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
}
}
/// <summary>
/// Session-scoped server-authoritative movement skills. Player-description
/// delivery and player-mode construction share this owner so rebuilding the
/// local physics controller cannot fall back to stale defaults.
/// </summary>
internal static class LocalPlayerSkillProjection
{
public static bool ApplyTo(
AcDream.Runtime.Gameplay.RuntimeMovementSkillState skills,
PlayerMovementController? controller)
{
ArgumentNullException.ThrowIfNull(skills);
AcDream.Runtime.Gameplay.RuntimeMovementSkillSnapshot snapshot =
skills.Snapshot;
if (controller is null || !snapshot.IsComplete)
return false;
controller.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill);
return true;
}
}
internal interface IViewportAspectSource
{
float Aspect { get; }

View file

@ -361,7 +361,9 @@ internal sealed class PlayerModeController :
exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
};
if (LocalPlayerSkillProjection.ApplyTo(_skills, controller))
if (RuntimeMovementSkillProjection.ApplyTo(
_skills,
controller))
{
Console.WriteLine(
$"live: {loggingTag} — applied server skills "

View file

@ -277,7 +277,7 @@ internal sealed class LiveSessionRuntimeFactory
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
if (LocalPlayerSkillProjection.ApplyTo(
if (RuntimeMovementSkillProjection.ApplyTo(
_domain.Character.MovementSkills,
_player.Controller.Controller))
{

View file

@ -164,11 +164,19 @@ public sealed class LandblockBuildFactory
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
merged.AddRange(BuildSceneryEntitiesForStreaming(
baseLoaded,
lbX,
lbY,
request.Origin));
merged.AddRange(
_dumpSceneryZ
? BuildSceneryEntitiesForStreaming(
baseLoaded,
lbX,
lbY,
request.Origin)
: LandblockPhysicsContentBuilder
.HydrateProceduralScenery(
_dats,
baseLoaded,
worldOffset,
_heightTable));
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
merged.AddRange(BuildInteriorEntitiesForStreaming(
landblockId,

View file

@ -4,6 +4,7 @@ using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.Content;
@ -16,10 +17,16 @@ namespace AcDream.Content;
/// </summary>
public static class LandblockPhysicsContentBuilder
{
public readonly record struct StaticCollisionPublication(
int BspOwnerCount,
int SetupOwnerCount,
int NoCollisionCount);
public static IReadOnlyList<WorldEntity> HydrateStaticEntities(
IDatReaderWriter dats,
LoadedLandblock source,
Vector3 worldOffset)
Vector3 worldOffset,
bool includeVisualBounds = true)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(source);
@ -35,13 +42,12 @@ public static class LandblockPhysicsContentBuilder
GfxObj? gfx = dats.Get<GfxObj>(sourceId);
if (gfx is not null)
{
(Vector3 Min, Vector3 Max)? partBounds =
GfxObjBounds.Get(gfx);
if (partBounds is not null)
if (includeVisualBounds
&& GfxObjBounds.Get(gfx) is { } partBounds)
{
bounds.Add(
Matrix4x4.Identity,
partBounds.Value);
partBounds);
}
meshRefs.Add(new MeshRef(
sourceId,
@ -59,13 +65,12 @@ public static class LandblockPhysicsContentBuilder
dats.Get<GfxObj>(meshRef.GfxObjId);
if (gfx is null)
continue;
(Vector3 Min, Vector3 Max)? partBounds =
GfxObjBounds.Get(gfx);
if (partBounds is not null)
if (includeVisualBounds
&& GfxObjBounds.Get(gfx) is { } partBounds)
{
bounds.Add(
meshRef.PartTransform,
partBounds.Value);
partBounds);
}
meshRefs.Add(meshRef);
}
@ -94,6 +99,151 @@ public static class LandblockPhysicsContentBuilder
return hydrated;
}
/// <summary>
/// Hydrates retail's deterministic Region/Scene-generated outdoor
/// scenery into the same immutable entity shape consumed by graphical
/// and direct hosts. The supplied offset is the host's current
/// landblock-relative world origin; DAT placement remains local.
/// </summary>
public static IReadOnlyList<WorldEntity> HydrateProceduralScenery(
IDatReaderWriter dats,
LoadedLandblock source,
Vector3 worldOffset,
ReadOnlySpan<float> heightTable,
bool includeVisualBounds = true)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(source);
if (heightTable.Length < 256)
{
throw new ArgumentException(
"The retail terrain height table must contain at least 256 entries.",
nameof(heightTable));
}
Region? region = dats.Get<Region>(0x13000000u);
if (region is null)
return [];
HashSet<int>? buildingCells = null;
LandBlockInfo? info = dats.Get<LandBlockInfo>(
(source.LandblockId & 0xFFFF0000u) | 0xFFFEu);
if (info is not null)
{
buildingCells = [];
foreach (BuildingInfo building in info.Buildings)
{
int cellX = Math.Clamp(
(int)(building.Frame.Origin.X / 24f),
0,
8);
int cellY = Math.Clamp(
(int)(building.Frame.Origin.Y / 24f),
0,
8);
buildingCells.Add(cellX * 9 + cellY);
}
}
IReadOnlyList<SceneryGenerator.ScenerySpawn> spawns =
SceneryGenerator.Generate(
dats,
region,
source.Heightmap,
source.LandblockId,
buildingCells);
if (spawns.Count == 0)
return [];
var entities = new List<WorldEntity>(spawns.Count);
uint landblockX =
(source.LandblockId >> 24) & 0xFFu;
uint landblockY =
(source.LandblockId >> 16) & 0xFFu;
uint sceneryCounter = 0u;
foreach (SceneryGenerator.ScenerySpawn spawn in spawns)
{
var meshRefs = new List<MeshRef>();
var bounds = new LocalBoundsAccumulator();
Matrix4x4 scale =
Matrix4x4.CreateScale(spawn.Scale);
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
{
GfxObj? gfx = dats.Get<GfxObj>(spawn.ObjectId);
if (gfx is not null)
{
if (includeVisualBounds
&& GfxObjBounds.Get(gfx) is { } partBounds)
{
bounds.Add(scale, partBounds);
}
meshRefs.Add(new MeshRef(spawn.ObjectId, scale));
}
}
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
{
Setup? setup = dats.Get<Setup>(spawn.ObjectId);
if (setup is not null)
{
foreach (MeshRef meshRef in SetupMesh.Flatten(setup))
{
GfxObj? gfx =
dats.Get<GfxObj>(meshRef.GfxObjId);
if (gfx is null)
continue;
Matrix4x4 partTransform =
meshRef.PartTransform * scale;
if (includeVisualBounds
&& GfxObjBounds.Get(gfx) is { } partBounds)
{
bounds.Add(partTransform, partBounds);
}
meshRefs.Add(new MeshRef(
meshRef.GfxObjId,
partTransform));
}
}
}
if (meshRefs.Count == 0)
continue;
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
float groundZ = TerrainSurface.SampleZFromHeightmap(
source.Heightmap.Height,
heightTable,
landblockX,
landblockY,
localX,
localY);
var entity = new WorldEntity
{
Id = ProceduralSceneryIdAllocator.Allocate(
landblockX,
landblockY,
ref sceneryCounter),
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new Vector3(
localX,
localY,
groundZ + spawn.LocalPosition.Z)
+ worldOffset,
Rotation = spawn.Rotation,
MeshRefs = meshRefs,
Scale = spawn.Scale,
EffectCellId =
TerrainSurface.ComputeOutdoorCellId(
source.LandblockId,
localX,
localY),
};
if (bounds.TryGet(out Vector3 min, out Vector3 max))
entity.SetLocalBounds(min, max);
entities.Add(entity);
}
return entities;
}
public static PhysicsDatBundle BuildDatBundle(
IDatReaderWriter dats,
uint landblockId,
@ -247,6 +397,353 @@ public static class LandblockPhysicsContentBuilder
envCellIds);
}
public static TerrainSurface BuildTerrainSurface(
LoadedLandblock landblock,
ReadOnlySpan<float> heightTable)
{
ArgumentNullException.ThrowIfNull(landblock);
if (heightTable.Length < 256)
{
throw new ArgumentException(
"The retail terrain height table must contain at least 256 entries.",
nameof(heightTable));
}
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
var terrainBytes = new byte[81];
for (int index = 0; index < terrainBytes.Length; index++)
{
terrainBytes[index] =
(byte)(ushort)landblock.Heightmap.Terrain[index];
}
return new TerrainSurface(
landblock.Heightmap.Height,
heightTable,
landblockX,
landblockY,
terrainBytes);
}
public static void PublishPreparedCells(
PhysicsDataCache cache,
LoadedLandblock landblock,
LandblockCollisionBuild collisions,
Vector3 origin,
ICollection<CellSurface> cellSurfaces,
ICollection<PortalPlane> portalPlanes)
{
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(landblock);
ArgumentNullException.ThrowIfNull(collisions);
ArgumentNullException.ThrowIfNull(cellSurfaces);
ArgumentNullException.ThrowIfNull(portalPlanes);
PhysicsDatBundle dats =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
uint count = dats.Info?.NumCells ?? 0u;
for (uint offset = 0; offset < count; offset++)
{
uint envCellId =
(landblock.LandblockId & 0xFFFF0000u)
| (0x0100u + offset);
if (!dats.EnvCells.TryGetValue(
envCellId,
out EnvCell? envCell)
|| !collisions.CellStructures.TryGetValue(
envCellId,
out FlatCellStructureCollisionAsset? structure)
|| !collisions.EnvCells.TryGetValue(
envCellId,
out FlatEnvCellTopology? topology))
{
continue;
}
Quaternion rotation = envCell.Position.Orientation;
Vector3 cellOriginWorld =
envCell.Position.Origin + origin;
Matrix4x4 transform =
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
cache.CacheCellStruct(
envCellId,
envCell,
transform,
structure,
topology);
FlatPolygonTable portalPolygons =
structure.PortalPolygons;
for (int portalIndex = 0;
portalIndex < topology.Portals.Length;
portalIndex++)
{
FlatEnvCellPortal portal =
topology.Portals[portalIndex];
if ((uint)portal.PolygonIndex
>= (uint)portalPolygons.Polygons.Length)
{
continue;
}
FlatCollisionPolygon polygon =
portalPolygons.Polygons[portal.PolygonIndex];
if (polygon.VertexRange.Count < 3)
continue;
var vertices =
new Vector3[polygon.VertexRange.Count];
for (int index = 0; index < vertices.Length; index++)
{
Vector3 local = portalPolygons.Vertices[
polygon.VertexRange.Start + index];
vertices[index] =
Vector3.Transform(local, rotation)
+ cellOriginWorld;
}
portalPlanes.Add(PortalPlane.FromVertices(
vertices.AsSpan(),
portal.OtherCellId,
envCellId & 0xFFFFu,
portal.Flags));
}
cellSurfaces.Add(new CellSurface(
envCellId,
structure.PhysicsBsp.PolygonTable,
rotation,
cellOriginWorld));
}
}
public static void CacheBuildings(
PhysicsDataCache cache,
LoadedLandblock landblock,
TerrainSurface terrain,
Vector3 origin)
{
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(landblock);
ArgumentNullException.ThrowIfNull(terrain);
PhysicsDatBundle dats =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
if (dats.Info is not { } info)
return;
uint prefix = landblock.LandblockId & 0xFFFF0000u;
foreach (BuildingInfo building in info.Buildings)
{
var portals =
new List<BldPortalInfo>(building.Portals.Count);
foreach (var portal in building.Portals)
{
portals.Add(new BldPortalInfo(
prefix | (uint)portal.OtherCellId,
unchecked((short)portal.OtherPortalId),
(ushort)portal.Flags));
}
Vector3 buildingOrigin = building.Frame.Origin + origin;
Matrix4x4 transform =
Matrix4x4.CreateFromQuaternion(
building.Frame.Orientation)
* Matrix4x4.CreateTranslation(buildingOrigin);
uint landcellId = prefix
| terrain.ComputeOutdoorCellId(
building.Frame.Origin.X,
building.Frame.Origin.Y);
uint shellPartZero = building.ModelId;
if ((shellPartZero & 0xFF000000u) == 0x02000000u)
{
dats.Setups.TryGetValue(
building.ModelId,
out Setup? setup);
shellPartZero =
setup is not null && setup.Parts.Count > 0
? setup.Parts[0]
: 0u;
}
cache.CacheBuilding(
landcellId,
portals,
transform,
shellPartZero);
}
}
public static void CachePreparedObjects(
PhysicsDataCache cache,
LandblockCollisionBuild collisions)
{
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(collisions);
foreach ((uint id, FlatGfxObjCollisionAsset asset) in
collisions.GfxObjs)
{
cache.CacheGfxObj(id, asset);
}
foreach ((uint id, FlatSetupCollision setup) in
collisions.Setups)
{
cache.CacheSetup(id, setup);
}
}
public static StaticCollisionPublication PublishStaticCollision(
PhysicsEngine engine,
PhysicsDataCache cache,
LoadedLandblock landblock,
LandblockCollisionBuild collisions,
Vector3 origin)
{
ArgumentNullException.ThrowIfNull(engine);
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(landblock);
ArgumentNullException.ThrowIfNull(collisions);
int bspOwners = 0;
int setupOwners = 0;
int noCollision = 0;
foreach (WorldEntity entity in landblock.Entities)
{
if (entity.IsBuildingShell)
continue;
IReadOnlyList<ShadowShape> bspShapes =
ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs,
entity.IsBuildingShell,
cache.GetGfxObj);
if (bspShapes.Count > 0)
{
engine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
bspShapes,
0u,
EntityCollisionFlags.None,
worldOffsetX: origin.X,
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
bspOwners++;
continue;
}
FlatSetupCollision? setup =
cache.GetFlatSetup(
entity.SourceGfxObjOrSetupId);
if (setup is null)
{
noCollision++;
continue;
}
float scale = entity.Scale > 0f ? entity.Scale : 1f;
var setupShapes = new List<ShadowShape>();
for (int index = 0;
index < setup.Cylinders.Length;
index++)
{
FlatCollisionCylinder cylinder =
setup.Cylinders[index];
float radius = cylinder.Radius * scale;
float height = (cylinder.Height > 0f
? cylinder.Height
: cylinder.Radius * 4f) * scale;
if (radius <= 0f)
continue;
setupShapes.Add(new ShadowShape(
entity.SourceGfxObjOrSetupId,
cylinder.Origin * scale,
Quaternion.Identity,
scale,
ShadowCollisionType.Cylinder,
radius,
height));
}
if (setup.Cylinders.Length == 0)
{
for (int index = 0;
index < setup.Spheres.Length;
index++)
{
FlatCollisionSphere sphere = setup.Spheres[index];
if (sphere.Radius <= 0f)
continue;
float radius = sphere.Radius * scale;
Vector3 localOffset = sphere.Origin * scale;
Vector3 localBaseOffset = localOffset
+ Vector3.Transform(
-Vector3.UnitZ * radius,
Quaternion.Inverse(entity.Rotation));
setupShapes.Add(new ShadowShape(
entity.SourceGfxObjOrSetupId,
localBaseOffset,
Quaternion.Identity,
scale,
ShadowCollisionType.Cylinder,
radius,
radius * 2f));
}
}
if (setup.Cylinders.Length == 0
&& setup.Spheres.Length == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
entity.SourceGfxObjOrSetupId,
Vector3.Zero,
Quaternion.Identity,
scale,
ShadowCollisionType.Cylinder,
radius,
height));
}
if (setupShapes.Count == 0)
{
noCollision++;
continue;
}
engine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
setupShapes,
0u,
EntityCollisionFlags.None,
worldOffsetX: origin.X,
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
setupOwners++;
}
uint[] reflood =
engine.ShadowObjects.CaptureRefloodOwnersForLandblock(
landblock.LandblockId);
foreach (uint ownerId in reflood)
{
engine.ShadowObjects.RefloodOwnerForLandblock(
ownerId,
landblock.LandblockId);
}
return new StaticCollisionPublication(
bspOwners,
setupOwners,
noCollision);
}
private static T Require<T>(
PreparedCollisionReadResult<T> result,
string kind,

View file

@ -56,9 +56,25 @@ public sealed class TerrainSurface
public TerrainSurface(byte[] heights, float[] heightTable,
uint landblockX = 0, uint landblockY = 0,
byte[]? terrainTypes = null)
: this(
heights,
(heightTable
?? throw new ArgumentNullException(nameof(heightTable)))
.AsSpan(),
landblockX,
landblockY,
terrainTypes)
{
}
public TerrainSurface(
byte[] heights,
ReadOnlySpan<float> heightTable,
uint landblockX = 0,
uint landblockY = 0,
byte[]? terrainTypes = null)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256)
@ -191,8 +207,25 @@ public sealed class TerrainSurface
uint landblockX, uint landblockY,
float localX, float localY)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
return SampleZFromHeightmap(
heights,
heightTable.AsSpan(),
landblockX,
landblockY,
localX,
localY);
}
public static float SampleZFromHeightmap(
byte[] heights,
ReadOnlySpan<float> heightTable,
uint landblockX,
uint landblockY,
float localX,
float localY)
{
ArgumentNullException.ThrowIfNull(heights);
if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256)

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));
}
}
}

View file

@ -972,7 +972,7 @@ public sealed class PlayerMovementController
/// Install a complete authoritative frame rotation (spawn, teleport, or
/// server correction) without collapsing it through the yaw projection.
/// </summary>
internal void SetBodyOrientation(Quaternion orientation)
public void SetBodyOrientation(Quaternion orientation)
{
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
_body.Position,

View file

@ -0,0 +1,24 @@
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Applies the exact server-owned run/jump snapshot to either host's one local
/// movement controller. This lives beside the canonical skill owner so
/// graphical and no-window construction cannot drift.
/// </summary>
public static class RuntimeMovementSkillProjection
{
public static bool ApplyTo(
RuntimeMovementSkillState skills,
PlayerMovementController? controller)
{
ArgumentNullException.ThrowIfNull(skills);
RuntimeMovementSkillSnapshot snapshot = skills.Snapshot;
if (controller is null || !snapshot.IsComplete)
return false;
controller.SetCharacterSkills(
snapshot.RunSkill,
snapshot.JumpSkill);
return true;
}
}

View file

@ -7,6 +7,23 @@ using AcDream.Runtime.World;
namespace AcDream.Runtime.Session;
public interface IRuntimeDirectWorldProjection
{
void ProjectSpawn(
RuntimeEntityRecord record,
bool isLocalPlayer);
void ProjectPosition(
RuntimeEntityRecord record,
bool isLocalPlayer);
void BeginTeleport();
RuntimeDestinationReadiness PrepareDestination(
long revealGeneration,
RuntimeTeleportDestination destination);
}
/// <summary>
/// Presentation-free inbound entity route for a direct Runtime host. It
/// applies the same canonical identity, timestamp, object-table, and transit
@ -18,15 +35,18 @@ public sealed class RuntimeLiveEntitySessionController
private readonly GameRuntime _runtime;
private readonly WorldSession _session;
private readonly Action<string> _log;
private readonly IRuntimeDirectWorldProjection? _worldProjection;
public RuntimeLiveEntitySessionController(
GameRuntime runtime,
WorldSession session,
Action<string>? log = null)
Action<string>? log = null,
IRuntimeDirectWorldProjection? worldProjection = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_session = session ?? throw new ArgumentNullException(nameof(session));
_log = log ?? (_ => { });
_worldProjection = worldProjection;
}
public LiveEntitySessionSink CreateSink() => new(
@ -54,13 +74,20 @@ public sealed class RuntimeLiveEntitySessionController
return;
ulong integrationVersion = canonical.CreateIntegrationVersion;
_ = Entities.ApplyAcceptedSpawn(
bool applied = Entities.ApplyAcceptedSpawn(
canonical,
integrationVersion,
canonical.Snapshot,
replaceGeneration:
registration.Inbound.Disposition
is CreateObjectTimestampDisposition.NewGeneration);
if (applied)
{
_worldProjection?.ProjectSpawn(
canonical,
canonical.ServerGuid
== _runtime.PlayerIdentity.ServerGuid);
}
}
private void OnDeleted(DeleteObject.Parsed delete)
@ -146,6 +173,14 @@ public sealed class RuntimeLiveEntitySessionController
_runtime.TransitOwner.OfferTeleportDestination(
destination,
timestamps.TeleportAdvanced);
if (Entities.Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord record))
{
_worldProjection?.ProjectPosition(
record,
isLocalPlayer: true);
}
TryCompletePortal();
}
@ -174,6 +209,7 @@ public sealed class RuntimeLiveEntitySessionController
RuntimeWorldTransitState transit = _runtime.TransitOwner;
if (!transit.TryQueueTeleportStart(sequence))
return;
_worldProjection?.BeginTeleport();
if (!transit.ActivateQueuedTeleport())
{
throw new InvalidOperationException(
@ -216,16 +252,21 @@ public sealed class RuntimeLiveEntitySessionController
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
RuntimeDestinationReadiness readiness =
_worldProjection?.PrepareDestination(
generation,
destination)
?? new RuntimeDestinationReadiness(
generation,
destination.CellId,
indoor,
IsUnhydratable: false,
RequiredRenderRadius: indoor ? 0 : 1,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: true);
if (!transit.AcknowledgeDestinationReadiness(
new RuntimeDestinationReadiness(
generation,
destination.CellId,
indoor,
IsUnhydratable: false,
RequiredRenderRadius: indoor ? 0 : 1,
IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true,
IsCollisionReady: true)))
readiness))
{
throw new InvalidOperationException(
"Runtime rejected headless destination readiness.");