From b6547ff38c375328417855200f41c8af6436f246 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 09:25:58 +0200 Subject: [PATCH] feat(headless): hydrate isolated collision worlds --- .../Input/LocalPlayerRuntimeState.cs | 22 - src/AcDream.App/Input/PlayerModeController.cs | 4 +- .../Net/LiveSessionRuntimeFactory.cs | 2 +- .../Streaming/LandblockBuildFactory.cs | 18 +- .../LandblockPhysicsContentBuilder.cs | 515 +++++++++++++++++- src/AcDream.Core/Physics/TerrainSurface.cs | 37 +- .../Hosting/HeadlessProcessContentOwner.cs | 47 +- .../Hosting/HeadlessProcessHost.cs | 5 +- .../Hosting/HeadlessProcessScheduler.cs | 59 +- .../Hosting/HeadlessSessionHost.cs | 57 +- .../Hosting/HeadlessSessionWorldProjection.cs | 433 +++++++++++++++ .../Hosting/HeadlessStaticStateAudit.cs | 49 ++ .../Gameplay/PlayerMovementController.cs | 2 +- .../RuntimeMovementSkillProjection.cs | 24 + .../RuntimeLiveEntitySessionController.cs | 63 ++- .../HeadlessProcessContentOwnerTests.cs | 19 +- .../HeadlessProcessSchedulerTests.cs | 118 +++- .../HeadlessSessionHostTests.cs | 111 +++- .../HeadlessSessionIsolationTests.cs | 376 +++++++++++++ ...RuntimeLiveEntitySessionControllerTests.cs | 100 ++++ 20 files changed, 1960 insertions(+), 101 deletions(-) create mode 100644 src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs create mode 100644 src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs create mode 100644 tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs 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,