diff --git a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
index 4a1c5924..8780eeda 100644
--- a/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
+++ b/src/AcDream.App/Input/LocalPlayerRuntimeState.cs
@@ -69,28 +69,6 @@ internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
}
}
-///
-/// 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.
-///
-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; }
diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs
index 81b9057c..1b76538c 100644
--- a/src/AcDream.App/Input/PlayerModeController.cs
+++ b/src/AcDream.App/Input/PlayerModeController.cs
@@ -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 "
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index d2bb6fc9..b51a2183 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -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))
{
diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
index 62573dd4..5e1e04cc 100644
--- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs
+++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs
@@ -164,11 +164,19 @@ public sealed class LandblockBuildFactory
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List(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,
diff --git a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs
index e406af38..e6228a8a 100644
--- a/src/AcDream.Content/LandblockPhysicsContentBuilder.cs
+++ b/src/AcDream.Content/LandblockPhysicsContentBuilder.cs
@@ -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;
///
public static class LandblockPhysicsContentBuilder
{
+ public readonly record struct StaticCollisionPublication(
+ int BspOwnerCount,
+ int SetupOwnerCount,
+ int NoCollisionCount);
+
public static IReadOnlyList 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(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(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;
}
+ ///
+ /// 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.
+ ///
+ public static IReadOnlyList HydrateProceduralScenery(
+ IDatReaderWriter dats,
+ LoadedLandblock source,
+ Vector3 worldOffset,
+ ReadOnlySpan 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(0x13000000u);
+ if (region is null)
+ return [];
+
+ HashSet? buildingCells = null;
+ LandBlockInfo? info = dats.Get(
+ (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 spawns =
+ SceneryGenerator.Generate(
+ dats,
+ region,
+ source.Heightmap,
+ source.LandblockId,
+ buildingCells);
+ if (spawns.Count == 0)
+ return [];
+
+ var entities = new List(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();
+ var bounds = new LocalBoundsAccumulator();
+ Matrix4x4 scale =
+ Matrix4x4.CreateScale(spawn.Scale);
+ if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
+ {
+ GfxObj? gfx = dats.Get(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(spawn.ObjectId);
+ if (setup is not null)
+ {
+ foreach (MeshRef meshRef in SetupMesh.Flatten(setup))
+ {
+ GfxObj? gfx =
+ dats.Get(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 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 cellSurfaces,
+ ICollection 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(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 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();
+ 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(
PreparedCollisionReadResult result,
string kind,
diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs
index d5f9e147..d6a528b9 100644
--- a/src/AcDream.Core/Physics/TerrainSurface.cs
+++ b/src/AcDream.Core/Physics/TerrainSurface.cs
@@ -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 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 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)
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
index 9b9bc37d..5c42ea75 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs
@@ -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 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(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 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 _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 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 _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 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 HeightTable
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_owner is null, this);
+ return _heightTable;
+ }
+ }
+
public void Dispose()
{
HeadlessProcessContentOwner? owner =
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
index 926ba949..7892b97a 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
@@ -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()
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessScheduler.cs b/src/AcDream.Headless/Hosting/HeadlessProcessScheduler.cs
index 43b336be..c29cc2b6 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessScheduler.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessScheduler.cs
@@ -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);
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 531d7728..dc9a8296 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -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(),
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
new file mode 100644
index 00000000..4f4f8f3c
--- /dev/null
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
@@ -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);
+}
+
+///
+/// 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.
+///
+internal sealed class HeadlessCollisionNeighborhood
+ : IHeadlessCollisionNeighborhood
+{
+ private readonly GameRuntime _runtime;
+ private readonly HeadlessProcessContentOwner
+ .HeadlessProcessContentLease _content;
+ private readonly HashSet _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 staticEntities =
+ LandblockPhysicsContentBuilder.HydrateStaticEntities(
+ _content.Dats,
+ source,
+ origin,
+ includeVisualBounds: false);
+ IReadOnlyList scenery =
+ LandblockPhysicsContentBuilder.HydrateProceduralScenery(
+ _content.Dats,
+ source,
+ origin,
+ _content.HeightTable.AsSpan(),
+ includeVisualBounds: false);
+ var entities = new List(
+ 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();
+ var portalPlanes = new List();
+ 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;
+}
+
+///
+/// No-window projection of canonical Runtime entity and transit state into
+/// one local movement controller plus its per-session collision world.
+///
+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 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;
+ }
+}
diff --git a/src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs b/src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
new file mode 100644
index 00000000..de7a05fd
--- /dev/null
+++ b/src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
@@ -0,0 +1,49 @@
+using System.Reflection;
+using AcDream.Core.Physics;
+using AcDream.Headless.Configuration;
+
+namespace AcDream.Headless.Hosting;
+
+///
+/// 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
+/// .
+///
+internal static class HeadlessStaticStateAudit
+{
+ internal static void ValidateProcessIsolation()
+ {
+ var enabled = new List();
+ 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));
+ }
+ }
+}
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index ba29fda7..dbf4d75a 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -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.
///
- internal void SetBodyOrientation(Quaternion orientation)
+ public void SetBodyOrientation(Quaternion orientation)
{
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
_body.Position,
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs
new file mode 100644
index 00000000..75ab1300
--- /dev/null
+++ b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs
@@ -0,0 +1,24 @@
+namespace AcDream.Runtime.Gameplay;
+
+///
+/// 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.
+///
+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;
+ }
+}
diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
index 17ab4117..8d0559b5 100644
--- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
+++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
@@ -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);
+}
+
///
/// 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 _log;
+ private readonly IRuntimeDirectWorldProjection? _worldProjection;
public RuntimeLiveEntitySessionController(
GameRuntime runtime,
WorldSession session,
- Action? log = null)
+ Action? 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.");
diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
index c70e57c0..7a285588 100644
--- a/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessProcessContentOwnerTests.cs
@@ -1,4 +1,6 @@
using System.Reflection;
+using System.Collections.Immutable;
+using System.Runtime.InteropServices;
using AcDream.Content;
using AcDream.Headless.Configuration;
using AcDream.Headless.Hosting;
@@ -114,6 +116,9 @@ public sealed class HeadlessProcessContentOwnerTests
IPreparedCollisionSource sharedCollision =
Assert.IsAssignableFrom(
host.Sessions[0].Content?.PreparedCollision);
+ float[] sharedHeightTable = Assert.IsType(
+ ImmutableCollectionsMarshal.AsArray(
+ host.Sessions[0].Content!.HeightTable));
Assert.All(
host.Sessions,
session =>
@@ -130,6 +135,10 @@ public sealed class HeadlessProcessContentOwnerTests
Assert.Same(
sharedCollision,
session.Content?.PreparedCollision);
+ Assert.Same(
+ sharedHeightTable,
+ ImmutableCollectionsMarshal.AsArray(
+ session.Content!.HeightTable));
});
Assert.Equal(
sessionCount,
@@ -151,6 +160,13 @@ public sealed class HeadlessProcessContentOwnerTests
session.Runtime.EntityObjects.Physics.DataCache)
.Distinct(ReferenceEqualityComparer.Instance)
.Count());
+ Assert.Equal(
+ sessionCount,
+ host.Sessions
+ .Select(static session =>
+ session.Runtime.MovementOwner)
+ .Distinct(ReferenceEqualityComparer.Instance)
+ .Count());
HeadlessSessionHost[] sessions = host.Sessions.ToArray();
host.Dispose();
@@ -223,7 +239,8 @@ public sealed class HeadlessProcessContentOwnerTests
return new HeadlessOpenedProcessContent(
DatsResource,
PreparedResource,
- MagicCatalog.Empty);
+ MagicCatalog.Empty,
+ ImmutableArray.CreateRange(new float[256]));
}
}
}
diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
index 05d0fd10..110a71f9 100644
--- a/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
@@ -6,6 +6,7 @@ using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Hosting;
using AcDream.Headless.Platform;
+using AcDream.Headless.Policies;
using AcDream.Runtime;
using AcDream.Runtime.Session;
@@ -186,11 +187,60 @@ public sealed class HeadlessProcessSchedulerTests
StringComparison.Ordinal);
}
+ [Fact]
+ public void ThrowingPolicyQuarantinesOnlyItsOwnSession()
+ {
+ var time = new ManualTimeProvider();
+ var operations = new FixtureSessionOperations();
+ var throwing = new ThrowingPolicy();
+ var healthy = new CountingPolicy();
+ using HeadlessSessionHost first =
+ CreateSession(
+ "fault",
+ time,
+ operations,
+ policyOverride: throwing);
+ using HeadlessSessionHost second =
+ CreateSession(
+ "healthy",
+ time,
+ operations,
+ policyOverride: healthy);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ first.Start().Status);
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ second.Start().Status);
+ var scheduler = new HeadlessProcessScheduler(
+ [first, second],
+ time);
+
+ time.Advance(TimeSpan.FromMilliseconds(15));
+ Assert.True(scheduler.DispatchDue(time.GetTimestamp()));
+
+ Assert.True(first.IsFaulted);
+ Assert.IsType(first.Fault);
+ Assert.False(second.IsFaulted);
+ Assert.Equal(1, healthy.TickCount);
+ HeadlessSchedulerSnapshot snapshot =
+ scheduler.CaptureSnapshot();
+ Assert.Equal(1, snapshot.FaultedSessionCount);
+ Assert.Equal(1, snapshot.ActiveSessionCount);
+
+ time.Advance(TimeSpan.FromMilliseconds(15));
+ Assert.True(scheduler.DispatchDue(time.GetTimestamp()));
+ Assert.Equal(2, healthy.TickCount);
+ Assert.Equal(1UL, first.Runtime.Clock.FrameNumber);
+ Assert.Equal(2UL, second.Runtime.Clock.FrameNumber);
+ }
+
private static HeadlessSessionHost CreateSession(
string id,
TimeProvider time,
ILiveSessionOperations operations,
- TimeSpan? reconnectQuiescence = null)
+ TimeSpan? reconnectQuiescence = null,
+ IHeadlessBotPolicy? policyOverride = null)
{
var credential = new HeadlessCredentialSecret(
$"{id}-credential",
@@ -203,7 +253,8 @@ public sealed class HeadlessProcessSchedulerTests
new HeadlessDiagnosticWriter(TextWriter.Null),
operations,
time,
- reconnectQuiescence);
+ reconnectQuiescence,
+ policyOverride: policyOverride);
}
catch
{
@@ -304,4 +355,67 @@ public sealed class HeadlessProcessSchedulerTests
session.Dispose();
}
}
+
+ private abstract class FixturePolicy : IHeadlessBotPolicy
+ {
+ public virtual bool IsComplete => false;
+
+ public abstract void Tick(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands);
+
+ public void OnLifecycle(in RuntimeLifecycleDelta delta)
+ {
+ }
+
+ public void OnCommand(in RuntimeCommandDelta delta)
+ {
+ }
+
+ public void OnEntity(in RuntimeEntityDelta delta)
+ {
+ }
+
+ public void OnInventory(in RuntimeInventoryDelta delta)
+ {
+ }
+
+ public void OnChat(in RuntimeChatDelta delta)
+ {
+ }
+
+ public void OnMovement(in RuntimeMovementDelta delta)
+ {
+ }
+
+ public void OnPortal(in RuntimePortalDelta delta)
+ {
+ }
+
+ public void OnCombat(in RuntimeCombatDelta delta)
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ private sealed class ThrowingPolicy : FixturePolicy
+ {
+ public override void Tick(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands) =>
+ throw new InvalidOperationException("injected policy fault");
+ }
+
+ private sealed class CountingPolicy : FixturePolicy
+ {
+ public int TickCount { get; private set; }
+
+ public override void Tick(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands) =>
+ TickCount++;
+ }
}
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
index a4215e4d..e64812be 100644
--- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
@@ -13,6 +13,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
+using AcDream.Runtime.World;
namespace AcDream.Headless.Tests;
@@ -186,6 +187,71 @@ public sealed class HeadlessSessionHostTests
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
}
+ [Fact]
+ public void WorldProjectionHydratesCanonicalMovementAndTeleportState()
+ {
+ var operations = new FixtureSessionOperations();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ Descriptor(),
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ GameRuntime runtime = host.Runtime;
+ const uint player = 0x50000002u;
+ runtime.PlayerIdentity.ServerGuid = player;
+ AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
+ RuntimeEntityRecord record = runtime.EntityObjects
+ .RegisterEntity(Spawn(player))
+ .Canonical!;
+ Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
+ record,
+ record.CreateIntegrationVersion,
+ record.Snapshot,
+ replaceGeneration: false));
+ var collision = new FixtureCollisionNeighborhood();
+ var projection = new HeadlessSessionWorldProjection(
+ runtime,
+ collision);
+
+ projection.ProjectSpawn(record, isLocalPlayer: true);
+ PlayerMovementController controller =
+ Assert.IsType(
+ runtime.MovementOwner.Controller);
+ projection.ProjectPosition(record, isLocalPlayer: true);
+
+ Assert.Same(controller, runtime.MovementOwner.Controller);
+ Assert.Equal(record.LocalEntityId, controller.LocalEntityId);
+ Assert.Equal(
+ 0xA9B40000u,
+ controller.CellId & 0xFFFF0000u);
+ Assert.True((controller.CellId & 0xFFFFu) < 0x0100u);
+ projection.BeginTeleport();
+ Assert.Equal(PlayerState.PortalSpace, controller.State);
+
+ RuntimeDestinationReadiness readiness =
+ projection.PrepareDestination(
+ revealGeneration: 7,
+ new RuntimeTeleportDestination(
+ player,
+ InstanceSequence: 1,
+ PositionSequence: 2,
+ TeleportSequence: 1,
+ ForcePositionSequence: 0,
+ new Position(
+ 0xA9B40001u,
+ new Vector3(96f, 97f, 50f),
+ Quaternion.Identity)));
+
+ Assert.True(readiness.IsCollisionReady);
+ Assert.False(readiness.IsUnhydratable);
+ Assert.Equal(PlayerState.InWorld, controller.State);
+ Assert.Equal(4, collision.CenterCount);
+ Assert.Equal(0xA9B40001u, collision.LastCell);
+ }
+
private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
@@ -217,18 +283,7 @@ public sealed class HeadlessSessionHostTests
{
const uint player = 0x50000002u;
PhysicsEngine engine = runtime.EntityObjects.Physics.Engine;
- var heights = new byte[81];
- Array.Fill(heights, (byte)50);
- var heightTable = new float[256];
- for (int index = 0; index < heightTable.Length; index++)
- heightTable[index] = index;
- engine.AddLandblock(
- 0xA9B4FFFFu,
- new TerrainSurface(heights, heightTable),
- [],
- [],
- worldOffsetX: 0f,
- worldOffsetY: 0f);
+ AddFlatLandblock(engine);
RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(Spawn(player))
@@ -246,6 +301,22 @@ public sealed class HeadlessSessionHostTests
runtime.MovementOwner.Controller = controller;
}
+ private static void AddFlatLandblock(PhysicsEngine engine)
+ {
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)50);
+ var heightTable = new float[256];
+ for (int index = 0; index < heightTable.Length; index++)
+ heightTable[index] = index;
+ engine.AddLandblock(
+ 0xA9B4FFFFu,
+ new TerrainSurface(heights, heightTable),
+ [],
+ [],
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+ }
+
private static WorldSession.EntitySpawn Spawn(uint guid)
{
var position = new CreateObject.ServerPosition(
@@ -389,4 +460,20 @@ public sealed class HeadlessSessionHostTests
}
}
+ private sealed class FixtureCollisionNeighborhood
+ : IHeadlessCollisionNeighborhood
+ {
+ public int CenterCount { get; private set; }
+ public uint LastCell { get; private set; }
+
+ public void CenterOn(uint fullCellId)
+ {
+ CenterCount++;
+ LastCell = fullCellId;
+ }
+
+ public bool IsReady(uint fullCellId) =>
+ fullCellId == LastCell;
+ }
+
}
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs
new file mode 100644
index 00000000..0d77c349
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs
@@ -0,0 +1,376 @@
+using System.Net;
+using System.Reflection;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Headless.Configuration;
+using AcDream.Headless.Credentials;
+using AcDream.Headless.Diagnostics;
+using AcDream.Headless.Hosting;
+using AcDream.Runtime;
+using AcDream.Runtime.Entities;
+using AcDream.Runtime.Session;
+
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessSessionIsolationTests
+{
+ [Theory]
+ [InlineData(1)]
+ [InlineData(5)]
+ [InlineData(10)]
+ public void SameServerIdentityStaysIsolatedAcrossActivityPortalAndReconnect(
+ int sessionCount)
+ {
+ const uint sharedPlayerGuid = 0x50000001u;
+ var operations = new FixtureOperations(sharedPlayerGuid);
+ HeadlessSessionHost[] hosts = Enumerable.Range(0, sessionCount)
+ .Select(index => CreateHost(index, operations))
+ .ToArray();
+ try
+ {
+ foreach (HeadlessSessionHost host in hosts)
+ {
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Start().Status);
+ }
+
+ var firstRecords = new RuntimeEntityRecord[sessionCount];
+ for (int index = 0; index < hosts.Length; index++)
+ {
+ HeadlessSessionHost host = hosts[index];
+ WorldSession session =
+ operations.ActiveSession(host.SessionId);
+ session.GameActionCapture = _ => { };
+ SpawnInto(session, sharedPlayerGuid, index + 1f);
+ RuntimeCommandResult selected =
+ host.Commands.Selection.SelectObject(
+ host.Runtime.Generation,
+ sharedPlayerGuid);
+ Assert.True(selected.Accepted);
+ Assert.True(
+ host.Runtime.EntityObjects.Entities.TryGetActive(
+ sharedPlayerGuid,
+ out RuntimeEntityRecord record));
+ firstRecords[index] = record;
+ Assert.Equal(
+ index + 1f,
+ record.Snapshot.Position!.Value.PositionX);
+
+ Portal(
+ session,
+ sharedPlayerGuid,
+ index + 20f);
+ Assert.True(host.Runtime.Portal.Snapshot.Completed);
+ Assert.True(
+ host.Runtime.TransitOwner
+ .CaptureOwnership()
+ .IsSessionIdle);
+ }
+
+ Assert.Equal(
+ sessionCount,
+ firstRecords
+ .Distinct(ReferenceEqualityComparer.Instance)
+ .Count());
+ Assert.Equal(
+ sessionCount,
+ hosts
+ .Select(static host => host.Runtime.Entities)
+ .Distinct(ReferenceEqualityComparer.Instance)
+ .Count());
+
+ for (int index = 0; index < hosts.Length; index++)
+ {
+ HeadlessSessionHost host = hosts[index];
+ ulong priorGeneration = host.Runtime.Generation.Value;
+ Assert.Equal(
+ RuntimeSessionStartStatus.Connected,
+ host.Reconnect().Status);
+ Assert.True(
+ host.Runtime.Generation.Value > priorGeneration);
+ Assert.Equal(0, host.Runtime.Entities.Count);
+
+ WorldSession replacement =
+ operations.ActiveSession(host.SessionId);
+ replacement.GameActionCapture = _ => { };
+ SpawnInto(
+ replacement,
+ sharedPlayerGuid,
+ index + 101f);
+ Assert.True(
+ host.Runtime.EntityObjects.Entities.TryGetActive(
+ sharedPlayerGuid,
+ out RuntimeEntityRecord replacementRecord));
+ Assert.NotSame(firstRecords[index], replacementRecord);
+ Assert.Equal(
+ index + 101f,
+ replacementRecord.Snapshot.Position!.Value.PositionX);
+ }
+ }
+ finally
+ {
+ for (int index = hosts.Length - 1; index >= 0; index--)
+ hosts[index].Dispose();
+ }
+
+ Assert.Equal(sessionCount * 2, operations.CreatedSessionCount);
+ Assert.Equal(sessionCount * 2, operations.DisposedSessionCount);
+ Assert.All(
+ hosts,
+ static host =>
+ Assert.True(host.Runtime.CaptureOwnership().IsConverged));
+ }
+
+ private static HeadlessSessionHost CreateHost(
+ int index,
+ FixtureOperations operations)
+ {
+ string id = $"bot-{index}";
+ var credential = new HeadlessCredentialSecret(
+ $"{id}-credential",
+ "fixture-password");
+ try
+ {
+ return new HeadlessSessionHost(
+ new HeadlessSessionDescriptor
+ {
+ Id = id,
+ Endpoint = new HeadlessEndpointDescriptor
+ {
+ Host = "127.0.0.1",
+ Port = 9000,
+ },
+ Account = id,
+ Character = new HeadlessCharacterSelector
+ {
+ Index = 0,
+ },
+ Policy = new HeadlessBotPolicyDescriptor
+ {
+ Id = "idle",
+ },
+ Credential = new HeadlessCredentialReference
+ {
+ Provider =
+ HeadlessCredentialProviderKind.StandardInput,
+ Reference = $"{id}-credential",
+ },
+ },
+ credential,
+ new HeadlessDiagnosticWriter(TextWriter.Null),
+ operations);
+ }
+ catch
+ {
+ credential.Dispose();
+ throw;
+ }
+ }
+
+ private static void SpawnInto(
+ WorldSession session,
+ uint guid,
+ float positionX) =>
+ EventDelegate>(
+ session,
+ nameof(session.EntitySpawned))(
+ Spawn(guid, positionX));
+
+ private static void Portal(
+ WorldSession session,
+ uint guid,
+ float positionX)
+ {
+ EventDelegate>(
+ session,
+ nameof(session.TeleportStarted))(1u);
+ EventDelegate>(
+ session,
+ nameof(session.PositionUpdated))(
+ new WorldSession.EntityPositionUpdate(
+ guid,
+ Position(positionX) with
+ {
+ LandblockId = 0x01020001u,
+ },
+ Velocity: null,
+ PlacementId: null,
+ IsGrounded: true,
+ InstanceSequence: 1,
+ PositionSequence: 2,
+ TeleportSequence: 1,
+ ForcePositionSequence: 0));
+ }
+
+ private static WorldSession.EntitySpawn Spawn(
+ uint guid,
+ float positionX)
+ {
+ CreateObject.ServerPosition position = Position(positionX);
+ var timestamps = new PhysicsTimestamps(
+ Position: 1,
+ Movement: 1,
+ State: 1,
+ Vector: 1,
+ Teleport: 0,
+ ServerControlledMove: 1,
+ ForcePosition: 0,
+ ObjDesc: 1,
+ Instance: 1);
+ var physics = new PhysicsSpawnData(
+ RawState: (uint)PhysicsStateFlags.ReportCollisions,
+ Position: position,
+ Movement: null,
+ AnimationFrame: null,
+ SetupTableId: 0x02000001u,
+ MotionTableId: null,
+ SoundTableId: null,
+ PhysicsScriptTableId: null,
+ Parent: null,
+ Children: null,
+ Scale: null,
+ Friction: null,
+ Elasticity: null,
+ Translucency: null,
+ Velocity: null,
+ Acceleration: null,
+ AngularVelocity: null,
+ DefaultScriptType: null,
+ DefaultScriptIntensity: null,
+ Timestamps: timestamps);
+ return new WorldSession.EntitySpawn(
+ guid,
+ position,
+ 0x02000001u,
+ [],
+ [],
+ [],
+ null,
+ null,
+ "Headless",
+ null,
+ null,
+ null,
+ PhysicsState: physics.RawState,
+ InstanceSequence: 1,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+
+ private static CreateObject.ServerPosition Position(float positionX) =>
+ new(
+ 0x01010001u,
+ positionX,
+ 10f,
+ 5f,
+ 1f,
+ 0f,
+ 0f,
+ 0f);
+
+ private static TDelegate EventDelegate(
+ WorldSession session,
+ string eventName)
+ where TDelegate : Delegate =>
+ Assert.IsType(
+ typeof(WorldSession).GetField(
+ eventName,
+ BindingFlags.Instance | BindingFlags.NonPublic)
+ ?.GetValue(session));
+
+ private sealed class FixtureOperations(uint sharedPlayerGuid)
+ : ILiveSessionOperations
+ {
+ private readonly Dictionary _active =
+ new(StringComparer.Ordinal);
+
+ public int CreatedSessionCount { get; private set; }
+ public int DisposedSessionCount { get; private set; }
+
+ public WorldSession ActiveSession(string account) =>
+ _active[account];
+
+ public IPEndPoint ResolveEndpoint(string host, int port) =>
+ new(IPAddress.Loopback, port);
+
+ public WorldSession CreateSession(IPEndPoint endpoint)
+ {
+ CreatedSessionCount++;
+ return new WorldSession(endpoint, new FixtureTransport());
+ }
+
+ public void Connect(
+ WorldSession session,
+ string user,
+ string password) =>
+ _active[user] = session;
+
+ public CharacterList.Parsed GetCharacters(
+ WorldSession session) =>
+ new(
+ 0u,
+ [
+ new CharacterList.Character(
+ sharedPlayerGuid,
+ "Headless",
+ 0u),
+ ],
+ [],
+ 11,
+ "account",
+ true,
+ true);
+
+ public void EnterWorld(
+ WorldSession session,
+ int activeCharacterIndex)
+ {
+ }
+
+ public void Tick(WorldSession session)
+ {
+ }
+
+ public void DisposeSession(WorldSession session)
+ {
+ DisposedSessionCount++;
+ session.Dispose();
+ }
+ }
+
+ private sealed class FixtureTransport : IWorldSessionTransport
+ {
+ public void Send(ReadOnlySpan datagram)
+ {
+ }
+
+ public void Send(
+ IPEndPoint remote,
+ ReadOnlySpan datagram)
+ {
+ }
+
+ public int Receive(
+ Span destination,
+ TimeSpan timeout,
+ out IPEndPoint? from)
+ {
+ from = null;
+ return -1;
+ }
+
+ public ValueTask ReceiveAsync(
+ Memory destination,
+ CancellationToken cancellationToken) =>
+ ValueTask.FromException(
+ new OperationCanceledException(cancellationToken));
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs
index 99e43d9d..12286bca 100644
--- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs
@@ -8,6 +8,7 @@ using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
+using AcDream.Runtime.World;
namespace AcDream.Runtime.Tests.Session;
@@ -102,6 +103,54 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell);
}
+ [Fact]
+ public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam()
+ {
+ using GameRuntime runtime = CreateRuntime();
+ const uint playerGuid = 0x50000002u;
+ runtime.PlayerIdentity.ServerGuid = playerGuid;
+ using var session = new WorldSession(
+ new IPEndPoint(IPAddress.Loopback, 9000),
+ new FixtureTransport());
+ session.GameActionCapture = _ => { };
+ var projection = new FixtureWorldProjection();
+ var controller = new RuntimeLiveEntitySessionController(
+ runtime,
+ session,
+ worldProjection: projection);
+ LiveEntitySessionSink sink = controller.CreateSink();
+ WorldSession.EntitySpawn spawn =
+ Spawn(playerGuid, incarnation: 1);
+
+ sink.Spawned(spawn);
+ sink.TeleportStarted(1u);
+ sink.PositionUpdated(new WorldSession.EntityPositionUpdate(
+ playerGuid,
+ spawn.Position!.Value with
+ {
+ LandblockId = 0x01020001u,
+ PositionX = 30f,
+ },
+ Velocity: null,
+ PlacementId: null,
+ IsGrounded: true,
+ InstanceSequence: 1,
+ PositionSequence: 2,
+ TeleportSequence: 1,
+ ForcePositionSequence: 0));
+
+ Assert.Equal(1, projection.SpawnCount);
+ Assert.Equal(1, projection.PositionCount);
+ Assert.Equal(1, projection.TeleportStartCount);
+ Assert.Equal(1, projection.PrepareCount);
+ Assert.True(projection.LastSpawnWasLocal);
+ Assert.True(projection.LastPositionWasLocal);
+ Assert.Equal(playerGuid, projection.LastRecord?.ServerGuid);
+ Assert.Equal(0x01020001u, projection.LastDestination.CellId);
+ Assert.True(runtime.TransitOwner.CaptureOwnership().IsSessionIdle);
+ Assert.True(runtime.Portal.Snapshot.Completed);
+ }
+
private static GameRuntime CreateRuntime()
{
var operations = new FixtureGameplayOperations();
@@ -211,6 +260,57 @@ public sealed class RuntimeLiveEntitySessionControllerTests
}
}
+ private sealed class FixtureWorldProjection
+ : IRuntimeDirectWorldProjection
+ {
+ public int SpawnCount { get; private set; }
+ public int PositionCount { get; private set; }
+ public int TeleportStartCount { get; private set; }
+ public int PrepareCount { get; private set; }
+ public bool LastSpawnWasLocal { get; private set; }
+ public bool LastPositionWasLocal { get; private set; }
+ public RuntimeEntityRecord? LastRecord { get; private set; }
+ public RuntimeTeleportDestination LastDestination { get; private set; }
+
+ public void ProjectSpawn(
+ RuntimeEntityRecord record,
+ bool isLocalPlayer)
+ {
+ SpawnCount++;
+ LastRecord = record;
+ LastSpawnWasLocal = isLocalPlayer;
+ }
+
+ public void ProjectPosition(
+ RuntimeEntityRecord record,
+ bool isLocalPlayer)
+ {
+ PositionCount++;
+ LastRecord = record;
+ LastPositionWasLocal = isLocalPlayer;
+ }
+
+ public void BeginTeleport() => TeleportStartCount++;
+
+ public RuntimeDestinationReadiness PrepareDestination(
+ long revealGeneration,
+ RuntimeTeleportDestination destination)
+ {
+ PrepareCount++;
+ LastDestination = destination;
+ bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
+ return new RuntimeDestinationReadiness(
+ revealGeneration,
+ destination.CellId,
+ indoor,
+ IsUnhydratable: false,
+ RequiredRenderRadius: indoor ? 0 : 1,
+ IsRenderNeighborhoodReady: true,
+ AreCompositeTexturesReady: true,
+ IsCollisionReady: true);
+ }
+ }
+
private sealed class FixtureGameplayOperations
: IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations,