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 internal interface IViewportAspectSource
{ {
float Aspect { get; } float Aspect { get; }

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ using AcDream.Core.Meshing;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DatEnvironment = DatReaderWriter.DBObjs.Environment; using DatEnvironment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.Content; namespace AcDream.Content;
@ -16,10 +17,16 @@ namespace AcDream.Content;
/// </summary> /// </summary>
public static class LandblockPhysicsContentBuilder public static class LandblockPhysicsContentBuilder
{ {
public readonly record struct StaticCollisionPublication(
int BspOwnerCount,
int SetupOwnerCount,
int NoCollisionCount);
public static IReadOnlyList<WorldEntity> HydrateStaticEntities( public static IReadOnlyList<WorldEntity> HydrateStaticEntities(
IDatReaderWriter dats, IDatReaderWriter dats,
LoadedLandblock source, LoadedLandblock source,
Vector3 worldOffset) Vector3 worldOffset,
bool includeVisualBounds = true)
{ {
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(source);
@ -35,13 +42,12 @@ public static class LandblockPhysicsContentBuilder
GfxObj? gfx = dats.Get<GfxObj>(sourceId); GfxObj? gfx = dats.Get<GfxObj>(sourceId);
if (gfx is not null) if (gfx is not null)
{ {
(Vector3 Min, Vector3 Max)? partBounds = if (includeVisualBounds
GfxObjBounds.Get(gfx); && GfxObjBounds.Get(gfx) is { } partBounds)
if (partBounds is not null)
{ {
bounds.Add( bounds.Add(
Matrix4x4.Identity, Matrix4x4.Identity,
partBounds.Value); partBounds);
} }
meshRefs.Add(new MeshRef( meshRefs.Add(new MeshRef(
sourceId, sourceId,
@ -59,13 +65,12 @@ public static class LandblockPhysicsContentBuilder
dats.Get<GfxObj>(meshRef.GfxObjId); dats.Get<GfxObj>(meshRef.GfxObjId);
if (gfx is null) if (gfx is null)
continue; continue;
(Vector3 Min, Vector3 Max)? partBounds = if (includeVisualBounds
GfxObjBounds.Get(gfx); && GfxObjBounds.Get(gfx) is { } partBounds)
if (partBounds is not null)
{ {
bounds.Add( bounds.Add(
meshRef.PartTransform, meshRef.PartTransform,
partBounds.Value); partBounds);
} }
meshRefs.Add(meshRef); meshRefs.Add(meshRef);
} }
@ -94,6 +99,151 @@ public static class LandblockPhysicsContentBuilder
return hydrated; 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( public static PhysicsDatBundle BuildDatBundle(
IDatReaderWriter dats, IDatReaderWriter dats,
uint landblockId, uint landblockId,
@ -247,6 +397,353 @@ public static class LandblockPhysicsContentBuilder
envCellIds); 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>( private static T Require<T>(
PreparedCollisionReadResult<T> result, PreparedCollisionReadResult<T> result,
string kind, string kind,

View file

@ -56,9 +56,25 @@ public sealed class TerrainSurface
public TerrainSurface(byte[] heights, float[] heightTable, public TerrainSurface(byte[] heights, float[] heightTable,
uint landblockX = 0, uint landblockY = 0, uint landblockX = 0, uint landblockY = 0,
byte[]? terrainTypes = null) 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(heights);
ArgumentNullException.ThrowIfNull(heightTable);
if (heights.Length < 81) if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights)); throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256) if (heightTable.Length < 256)
@ -191,8 +207,25 @@ public sealed class TerrainSurface
uint landblockX, uint landblockY, uint landblockX, uint landblockY,
float localX, float localY) float localX, float localY)
{ {
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable); 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) if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights)); throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256) if (heightTable.Length < 256)

View file

@ -1,5 +1,7 @@
using System.Collections.Immutable;
using AcDream.Content; using AcDream.Content;
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
using DatReaderWriter.DBObjs;
namespace AcDream.Headless.Hosting; namespace AcDream.Headless.Hosting;
@ -25,7 +27,8 @@ internal interface IHeadlessProcessContentFactory
internal sealed record HeadlessOpenedProcessContent( internal sealed record HeadlessOpenedProcessContent(
IDatReaderWriter Dats, IDatReaderWriter Dats,
IPreparedAssetSource Prepared, IPreparedAssetSource Prepared,
MagicCatalog Magic); MagicCatalog Magic,
ImmutableArray<float> HeightTable);
internal sealed class ProductionHeadlessProcessContentFactory internal sealed class ProductionHeadlessProcessContentFactory
: IHeadlessProcessContentFactory : IHeadlessProcessContentFactory
@ -50,10 +53,24 @@ internal sealed class ProductionHeadlessProcessContentFactory
dats, dats,
diagnostic); diagnostic);
MagicCatalog magic = MagicCatalog.Load(dats); 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( return new HeadlessOpenedProcessContent(
dats, dats,
prepared, prepared,
magic); magic,
heightTable);
} }
catch catch
{ {
@ -80,6 +97,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
{ {
private readonly object _gate = new(); private readonly object _gate = new();
private readonly MagicCatalog _magic; private readonly MagicCatalog _magic;
private readonly ImmutableArray<float> _heightTable;
private IDatReaderWriter? _dats; private IDatReaderWriter? _dats;
private IPreparedAssetSource? _prepared; private IPreparedAssetSource? _prepared;
private SharedPreparedCollisionCache? _collision; private SharedPreparedCollisionCache? _collision;
@ -105,8 +123,13 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
IDatReaderWriter? dats = opened.Dats; IDatReaderWriter? dats = opened.Dats;
IPreparedAssetSource? prepared = opened.Prepared; IPreparedAssetSource? prepared = opened.Prepared;
MagicCatalog? magic = opened.Magic; MagicCatalog? magic = opened.Magic;
ImmutableArray<float> heightTable = opened.HeightTable;
bool incomplete = 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) if (incomplete || prepared is not IPreparedCollisionSource)
{ {
try try
@ -127,6 +150,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
_dats = dats!; _dats = dats!;
_prepared = prepared!; _prepared = prepared!;
_magic = magic!; _magic = magic!;
_heightTable = heightTable;
_collision = new SharedPreparedCollisionCache( _collision = new SharedPreparedCollisionCache(
(IPreparedCollisionSource)prepared!); (IPreparedCollisionSource)prepared!);
} }
@ -149,7 +173,8 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
_dats!, _dats!,
_prepared!, _prepared!,
_collision!, _collision!,
_magic); _magic,
_heightTable);
} }
} }
@ -220,6 +245,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
private readonly IPreparedAssetSource _prepared; private readonly IPreparedAssetSource _prepared;
private readonly IPreparedCollisionSource _collision; private readonly IPreparedCollisionSource _collision;
private readonly MagicCatalog _magic; private readonly MagicCatalog _magic;
private readonly ImmutableArray<float> _heightTable;
internal HeadlessProcessContentLease( internal HeadlessProcessContentLease(
HeadlessProcessContentOwner owner, HeadlessProcessContentOwner owner,
@ -227,7 +253,8 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource prepared, IPreparedAssetSource prepared,
IPreparedCollisionSource collision, IPreparedCollisionSource collision,
MagicCatalog magic) MagicCatalog magic,
ImmutableArray<float> heightTable)
{ {
_owner = owner; _owner = owner;
SessionId = sessionId; SessionId = sessionId;
@ -235,6 +262,7 @@ internal sealed class HeadlessProcessContentOwner : IDisposable
_prepared = prepared; _prepared = prepared;
_collision = collision; _collision = collision;
_magic = magic; _magic = magic;
_heightTable = heightTable;
} }
internal string SessionId { get; } 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() public void Dispose()
{ {
HeadlessProcessContentOwner? owner = HeadlessProcessContentOwner? owner =

View file

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

View file

@ -75,9 +75,12 @@ internal sealed class HeadlessProcessScheduler
internal HeadlessSchedulerSnapshot CaptureSnapshot() internal HeadlessSchedulerSnapshot CaptureSnapshot()
{ {
int activeSessionCount = 0; int activeSessionCount = 0;
int faultedSessionCount = 0;
for (int index = 0; index < _slots.Length; index++) 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++; activeSessionCount++;
} }
@ -87,6 +90,7 @@ internal sealed class HeadlessProcessScheduler
Interlocked.Read(ref _turnCount), Interlocked.Read(ref _turnCount),
Interlocked.Read(ref _catchUpCollapseCount), Interlocked.Read(ref _catchUpCollapseCount),
activeSessionCount, activeSessionCount,
faultedSessionCount,
NextDeadline()); NextDeadline());
} }
@ -121,6 +125,8 @@ internal sealed class HeadlessProcessScheduler
if (session.IsPolicyComplete) if (session.IsPolicyComplete)
continue; continue;
try
{
if (session.IsReconnectPending) if (session.IsReconnectPending)
{ {
if (nowTimestamp < session.ReconnectDeadline) if (nowTimestamp < session.ReconnectDeadline)
@ -149,6 +155,12 @@ internal sealed class HeadlessProcessScheduler
DispatchSessionDue(slot, nowTimestamp); DispatchSessionDue(slot, nowTimestamp);
dispatched = true; dispatched = true;
} }
catch (Exception error)
{
session.Quarantine(error);
dispatched = true;
}
}
return dispatched; return dispatched;
} }
@ -246,4 +258,5 @@ internal readonly record struct HeadlessSchedulerSnapshot(
long TurnCount, long TurnCount,
long CatchUpCollapseCount, long CatchUpCollapseCount,
int ActiveSessionCount, int ActiveSessionCount,
int FaultedSessionCount,
long NextDeadline); long NextDeadline);

View file

@ -124,6 +124,8 @@ internal sealed class HeadlessSessionHost : IDisposable
private bool _reconnectPending; private bool _reconnectPending;
private ulong _stoppedGeneration; private ulong _stoppedGeneration;
private string _accountName = string.Empty; private string _accountName = string.Empty;
private Exception? _fault;
private bool _faulted;
private bool _disposed; private bool _disposed;
internal HeadlessSessionHost( internal HeadlessSessionHost(
@ -134,7 +136,8 @@ internal sealed class HeadlessSessionHost : IDisposable
TimeProvider? timeProvider = null, TimeProvider? timeProvider = null,
TimeSpan? reconnectQuiescence = null, TimeSpan? reconnectQuiescence = null,
HeadlessProcessContentOwner.HeadlessProcessContentLease? HeadlessProcessContentOwner.HeadlessProcessContentLease?
contentLease = null) contentLease = null,
IHeadlessBotPolicy? policyOverride = null)
{ {
_descriptor = descriptor _descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor)); ?? throw new ArgumentNullException(nameof(descriptor));
@ -235,8 +238,8 @@ internal sealed class HeadlessSessionHost : IDisposable
hostLease = runtime.AcquireHostLease( hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}"); $"headless:{descriptor.Id}");
policy = policy = policyOverride
HeadlessBotPolicyFactory.Create(descriptor.Policy.Id); ?? HeadlessBotPolicyFactory.Create(descriptor.Policy.Id);
policySubscription = runtime.Subscribe(policy); policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle( diagnostics.Lifecycle(
descriptor.Id, descriptor.Id,
@ -264,7 +267,10 @@ internal sealed class HeadlessSessionHost : IDisposable
internal string SessionId => _descriptor.Id; internal string SessionId => _descriptor.Id;
internal string ActiveCharacterName { get; private set; } = internal string ActiveCharacterName { get; private set; } =
string.Empty; 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 bool IsReconnectPending => _reconnectPending;
internal HeadlessProcessContentOwner.HeadlessProcessContentLease? internal HeadlessProcessContentOwner.HeadlessProcessContentLease?
Content => _contentLease; Content => _contentLease;
@ -296,6 +302,42 @@ internal sealed class HeadlessSessionHost : IDisposable
internal RuntimeTeardownAcknowledgement Stop() => internal RuntimeTeardownAcknowledgement Stop() =>
Commands.Session.Stop(Runtime.Generation); 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( internal RuntimeSessionStartResult CompletePendingReconnect(
long nowTimestamp) long nowTimestamp)
{ {
@ -482,13 +524,18 @@ internal sealed class HeadlessSessionHost : IDisposable
private LiveSessionEventRouter CreateEventRoute( private LiveSessionEventRouter CreateEventRoute(
AcDream.Core.Net.WorldSession session) AcDream.Core.Net.WorldSession session)
{ {
IRuntimeDirectWorldProjection? worldProjection =
_contentLease is { } content
? new HeadlessSessionWorldProjection(Runtime, content)
: null;
var entities = new RuntimeLiveEntitySessionController( var entities = new RuntimeLiveEntitySessionController(
Runtime, Runtime,
session, session,
message => _diagnostics.Message( message => _diagnostics.Message(
_descriptor.Id, _descriptor.Id,
message, message,
Runtime.Generation.Value)); Runtime.Generation.Value),
worldProjection);
return new LiveSessionEventRouter( return new LiveSessionEventRouter(
session, session,
entities.CreateSink(), 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 /// Install a complete authoritative frame rotation (spawn, teleport, or
/// server correction) without collapsing it through the yaw projection. /// server correction) without collapsing it through the yaw projection.
/// </summary> /// </summary>
internal void SetBodyOrientation(Quaternion orientation) public void SetBodyOrientation(Quaternion orientation)
{ {
_body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate(
_body.Position, _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; 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> /// <summary>
/// Presentation-free inbound entity route for a direct Runtime host. It /// Presentation-free inbound entity route for a direct Runtime host. It
/// applies the same canonical identity, timestamp, object-table, and transit /// applies the same canonical identity, timestamp, object-table, and transit
@ -18,15 +35,18 @@ public sealed class RuntimeLiveEntitySessionController
private readonly GameRuntime _runtime; private readonly GameRuntime _runtime;
private readonly WorldSession _session; private readonly WorldSession _session;
private readonly Action<string> _log; private readonly Action<string> _log;
private readonly IRuntimeDirectWorldProjection? _worldProjection;
public RuntimeLiveEntitySessionController( public RuntimeLiveEntitySessionController(
GameRuntime runtime, GameRuntime runtime,
WorldSession session, WorldSession session,
Action<string>? log = null) Action<string>? log = null,
IRuntimeDirectWorldProjection? worldProjection = null)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_session = session ?? throw new ArgumentNullException(nameof(session)); _session = session ?? throw new ArgumentNullException(nameof(session));
_log = log ?? (_ => { }); _log = log ?? (_ => { });
_worldProjection = worldProjection;
} }
public LiveEntitySessionSink CreateSink() => new( public LiveEntitySessionSink CreateSink() => new(
@ -54,13 +74,20 @@ public sealed class RuntimeLiveEntitySessionController
return; return;
ulong integrationVersion = canonical.CreateIntegrationVersion; ulong integrationVersion = canonical.CreateIntegrationVersion;
_ = Entities.ApplyAcceptedSpawn( bool applied = Entities.ApplyAcceptedSpawn(
canonical, canonical,
integrationVersion, integrationVersion,
canonical.Snapshot, canonical.Snapshot,
replaceGeneration: replaceGeneration:
registration.Inbound.Disposition registration.Inbound.Disposition
is CreateObjectTimestampDisposition.NewGeneration); is CreateObjectTimestampDisposition.NewGeneration);
if (applied)
{
_worldProjection?.ProjectSpawn(
canonical,
canonical.ServerGuid
== _runtime.PlayerIdentity.ServerGuid);
}
} }
private void OnDeleted(DeleteObject.Parsed delete) private void OnDeleted(DeleteObject.Parsed delete)
@ -146,6 +173,14 @@ public sealed class RuntimeLiveEntitySessionController
_runtime.TransitOwner.OfferTeleportDestination( _runtime.TransitOwner.OfferTeleportDestination(
destination, destination,
timestamps.TeleportAdvanced); timestamps.TeleportAdvanced);
if (Entities.Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord record))
{
_worldProjection?.ProjectPosition(
record,
isLocalPlayer: true);
}
TryCompletePortal(); TryCompletePortal();
} }
@ -174,6 +209,7 @@ public sealed class RuntimeLiveEntitySessionController
RuntimeWorldTransitState transit = _runtime.TransitOwner; RuntimeWorldTransitState transit = _runtime.TransitOwner;
if (!transit.TryQueueTeleportStart(sequence)) if (!transit.TryQueueTeleportStart(sequence))
return; return;
_worldProjection?.BeginTeleport();
if (!transit.ActivateQueuedTeleport()) if (!transit.ActivateQueuedTeleport())
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@ -216,8 +252,11 @@ public sealed class RuntimeLiveEntitySessionController
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered); RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u; bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
if (!transit.AcknowledgeDestinationReadiness( RuntimeDestinationReadiness readiness =
new RuntimeDestinationReadiness( _worldProjection?.PrepareDestination(
generation,
destination)
?? new RuntimeDestinationReadiness(
generation, generation,
destination.CellId, destination.CellId,
indoor, indoor,
@ -225,7 +264,9 @@ public sealed class RuntimeLiveEntitySessionController
RequiredRenderRadius: indoor ? 0 : 1, RequiredRenderRadius: indoor ? 0 : 1,
IsRenderNeighborhoodReady: true, IsRenderNeighborhoodReady: true,
AreCompositeTexturesReady: true, AreCompositeTexturesReady: true,
IsCollisionReady: true))) IsCollisionReady: true);
if (!transit.AcknowledgeDestinationReadiness(
readiness))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"Runtime rejected headless destination readiness."); "Runtime rejected headless destination readiness.");

View file

@ -1,4 +1,6 @@
using System.Reflection; using System.Reflection;
using System.Collections.Immutable;
using System.Runtime.InteropServices;
using AcDream.Content; using AcDream.Content;
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
using AcDream.Headless.Hosting; using AcDream.Headless.Hosting;
@ -114,6 +116,9 @@ public sealed class HeadlessProcessContentOwnerTests
IPreparedCollisionSource sharedCollision = IPreparedCollisionSource sharedCollision =
Assert.IsAssignableFrom<IPreparedCollisionSource>( Assert.IsAssignableFrom<IPreparedCollisionSource>(
host.Sessions[0].Content?.PreparedCollision); host.Sessions[0].Content?.PreparedCollision);
float[] sharedHeightTable = Assert.IsType<float[]>(
ImmutableCollectionsMarshal.AsArray(
host.Sessions[0].Content!.HeightTable));
Assert.All( Assert.All(
host.Sessions, host.Sessions,
session => session =>
@ -130,6 +135,10 @@ public sealed class HeadlessProcessContentOwnerTests
Assert.Same( Assert.Same(
sharedCollision, sharedCollision,
session.Content?.PreparedCollision); session.Content?.PreparedCollision);
Assert.Same(
sharedHeightTable,
ImmutableCollectionsMarshal.AsArray(
session.Content!.HeightTable));
}); });
Assert.Equal( Assert.Equal(
sessionCount, sessionCount,
@ -151,6 +160,13 @@ public sealed class HeadlessProcessContentOwnerTests
session.Runtime.EntityObjects.Physics.DataCache) session.Runtime.EntityObjects.Physics.DataCache)
.Distinct(ReferenceEqualityComparer.Instance) .Distinct(ReferenceEqualityComparer.Instance)
.Count()); .Count());
Assert.Equal(
sessionCount,
host.Sessions
.Select(static session =>
session.Runtime.MovementOwner)
.Distinct(ReferenceEqualityComparer.Instance)
.Count());
HeadlessSessionHost[] sessions = host.Sessions.ToArray(); HeadlessSessionHost[] sessions = host.Sessions.ToArray();
host.Dispose(); host.Dispose();
@ -223,7 +239,8 @@ public sealed class HeadlessProcessContentOwnerTests
return new HeadlessOpenedProcessContent( return new HeadlessOpenedProcessContent(
DatsResource, DatsResource,
PreparedResource, PreparedResource,
MagicCatalog.Empty); MagicCatalog.Empty,
ImmutableArray.CreateRange(new float[256]));
} }
} }
} }

View file

@ -6,6 +6,7 @@ using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics; using AcDream.Headless.Diagnostics;
using AcDream.Headless.Hosting; using AcDream.Headless.Hosting;
using AcDream.Headless.Platform; using AcDream.Headless.Platform;
using AcDream.Headless.Policies;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -186,11 +187,60 @@ public sealed class HeadlessProcessSchedulerTests
StringComparison.Ordinal); 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<InvalidOperationException>(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( private static HeadlessSessionHost CreateSession(
string id, string id,
TimeProvider time, TimeProvider time,
ILiveSessionOperations operations, ILiveSessionOperations operations,
TimeSpan? reconnectQuiescence = null) TimeSpan? reconnectQuiescence = null,
IHeadlessBotPolicy? policyOverride = null)
{ {
var credential = new HeadlessCredentialSecret( var credential = new HeadlessCredentialSecret(
$"{id}-credential", $"{id}-credential",
@ -203,7 +253,8 @@ public sealed class HeadlessProcessSchedulerTests
new HeadlessDiagnosticWriter(TextWriter.Null), new HeadlessDiagnosticWriter(TextWriter.Null),
operations, operations,
time, time,
reconnectQuiescence); reconnectQuiescence,
policyOverride: policyOverride);
} }
catch catch
{ {
@ -304,4 +355,67 @@ public sealed class HeadlessProcessSchedulerTests
session.Dispose(); 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++;
}
} }

View file

@ -13,6 +13,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.Runtime.World;
namespace AcDream.Headless.Tests; namespace AcDream.Headless.Tests;
@ -186,6 +187,71 @@ public sealed class HeadlessSessionHostTests
Assert.True(host.Runtime.CaptureOwnership().IsConverged); 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<PlayerMovementController>(
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( private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment, HeadlessCredentialProviderKind.Environment,
@ -217,18 +283,7 @@ public sealed class HeadlessSessionHostTests
{ {
const uint player = 0x50000002u; const uint player = 0x50000002u;
PhysicsEngine engine = runtime.EntityObjects.Physics.Engine; PhysicsEngine engine = runtime.EntityObjects.Physics.Engine;
var heights = new byte[81]; AddFlatLandblock(engine);
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);
RuntimeEntityRecord record = runtime.EntityObjects RuntimeEntityRecord record = runtime.EntityObjects
.RegisterEntity(Spawn(player)) .RegisterEntity(Spawn(player))
@ -246,6 +301,22 @@ public sealed class HeadlessSessionHostTests
runtime.MovementOwner.Controller = controller; 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) private static WorldSession.EntitySpawn Spawn(uint guid)
{ {
var position = new CreateObject.ServerPosition( 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;
}
} }

View file

@ -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<Action<WorldSession.EntitySpawn>>(
session,
nameof(session.EntitySpawned))(
Spawn(guid, positionX));
private static void Portal(
WorldSession session,
uint guid,
float positionX)
{
EventDelegate<Action<uint>>(
session,
nameof(session.TeleportStarted))(1u);
EventDelegate<Action<WorldSession.EntityPositionUpdate>>(
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<TDelegate>(
WorldSession session,
string eventName)
where TDelegate : Delegate =>
Assert.IsType<TDelegate>(
typeof(WorldSession).GetField(
eventName,
BindingFlags.Instance | BindingFlags.NonPublic)
?.GetValue(session));
private sealed class FixtureOperations(uint sharedPlayerGuid)
: ILiveSessionOperations
{
private readonly Dictionary<string, WorldSession> _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<byte> datagram)
{
}
public void Send(
IPEndPoint remote,
ReadOnlySpan<byte> datagram)
{
}
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromException<NetReceiveResult>(
new OperationCanceledException(cancellationToken));
public void Dispose()
{
}
}
}

View file

@ -8,6 +8,7 @@ using AcDream.Core.Spells;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.Runtime.World;
namespace AcDream.Runtime.Tests.Session; namespace AcDream.Runtime.Tests.Session;
@ -102,6 +103,54 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.Equal(0x01020001u, runtime.Portal.Snapshot.DestinationCell); 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() private static GameRuntime CreateRuntime()
{ {
var operations = new FixtureGameplayOperations(); 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 private sealed class FixtureGameplayOperations
: IRuntimeCombatAttackOperations, : IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations, IRuntimeCombatTargetOperations,