feat(headless): hydrate isolated collision worlds
This commit is contained in:
parent
9569dadb57
commit
b6547ff38c
20 changed files with 1960 additions and 101 deletions
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
baseLoaded,
|
_dumpSceneryZ
|
||||||
lbX,
|
? BuildSceneryEntitiesForStreaming(
|
||||||
lbY,
|
baseLoaded,
|
||||||
request.Origin));
|
lbX,
|
||||||
|
lbY,
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,33 +125,41 @@ internal sealed class HeadlessProcessScheduler
|
||||||
if (session.IsPolicyComplete)
|
if (session.IsPolicyComplete)
|
||||||
continue;
|
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;
|
continue;
|
||||||
|
|
||||||
RuntimeSessionStartResult result =
|
DispatchSessionDue(slot, nowTimestamp);
|
||||||
session.CompletePendingReconnect(nowTimestamp);
|
dispatched = true;
|
||||||
if (result.Status
|
}
|
||||||
is not RuntimeSessionStartStatus.Connected)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
throw result.Error
|
session.Quarantine(error);
|
||||||
?? new InvalidOperationException(
|
|
||||||
$"Headless session reconnect completed with {result.Status}.");
|
|
||||||
}
|
|
||||||
slot.LastTurnTimestamp = nowTimestamp;
|
|
||||||
slot.NextDeadline = AddTicks(
|
|
||||||
nowTimestamp,
|
|
||||||
_periodTicks);
|
|
||||||
dispatched = true;
|
dispatched = true;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nowTimestamp < slot.NextDeadline)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
DispatchSessionDue(slot, nowTimestamp);
|
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
433
src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
Normal file
433
src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.Physics;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
internal interface IHeadlessCollisionNeighborhood
|
||||||
|
{
|
||||||
|
void CenterOn(uint fullCellId);
|
||||||
|
|
||||||
|
bool IsReady(uint fullCellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-session mutable collision publication over process-shared immutable
|
||||||
|
/// DAT and pak inputs. Every session retains its own engine, data cache,
|
||||||
|
/// cell graph, shadow registry, and publication ledger.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeadlessCollisionNeighborhood
|
||||||
|
: IHeadlessCollisionNeighborhood
|
||||||
|
{
|
||||||
|
private readonly GameRuntime _runtime;
|
||||||
|
private readonly HeadlessProcessContentOwner
|
||||||
|
.HeadlessProcessContentLease _content;
|
||||||
|
private readonly HashSet<uint> _resident = [];
|
||||||
|
private uint _centerLandblock;
|
||||||
|
|
||||||
|
internal HeadlessCollisionNeighborhood(
|
||||||
|
GameRuntime runtime,
|
||||||
|
HeadlessProcessContentOwner.HeadlessProcessContentLease content)
|
||||||
|
{
|
||||||
|
_runtime = runtime
|
||||||
|
?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
_content = content
|
||||||
|
?? throw new ArgumentNullException(nameof(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CenterOn(uint fullCellId)
|
||||||
|
{
|
||||||
|
uint center = CanonicalLandblock(fullCellId);
|
||||||
|
if (center == 0x0000FFFFu)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(fullCellId),
|
||||||
|
"A collision neighborhood requires a real destination cell.");
|
||||||
|
}
|
||||||
|
if (_centerLandblock == center
|
||||||
|
&& _resident.Contains(center)
|
||||||
|
&& _runtime.EntityObjects.Physics.Engine
|
||||||
|
.IsLandblockTerrainResident(center))
|
||||||
|
{
|
||||||
|
_runtime.EntityObjects.Physics.Engine
|
||||||
|
.UpdatePlayerCurrCell(fullCellId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RetireAll();
|
||||||
|
int centerX = (int)((center >> 24) & 0xFFu);
|
||||||
|
int centerY = (int)((center >> 16) & 0xFFu);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PublishOne(
|
||||||
|
center,
|
||||||
|
Vector3.Zero,
|
||||||
|
fullCellId,
|
||||||
|
required: true);
|
||||||
|
for (int dx = -1; dx <= 1; dx++)
|
||||||
|
{
|
||||||
|
for (int dy = -1; dy <= 1; dy++)
|
||||||
|
{
|
||||||
|
if (dx == 0 && dy == 0)
|
||||||
|
continue;
|
||||||
|
int landblockX = centerX + dx;
|
||||||
|
int landblockY = centerY + dy;
|
||||||
|
if ((uint)landblockX > byte.MaxValue
|
||||||
|
|| (uint)landblockY > byte.MaxValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uint landblockId =
|
||||||
|
((uint)landblockX << 24)
|
||||||
|
| ((uint)landblockY << 16)
|
||||||
|
| 0xFFFFu;
|
||||||
|
PublishOne(
|
||||||
|
landblockId,
|
||||||
|
new Vector3(dx * 192f, dy * 192f, 0f),
|
||||||
|
fullCellId,
|
||||||
|
required: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_centerLandblock = center;
|
||||||
|
_runtime.EntityObjects.Physics.Engine
|
||||||
|
.UpdatePlayerCurrCell(fullCellId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
RetireAll();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReady(uint fullCellId)
|
||||||
|
{
|
||||||
|
uint center = CanonicalLandblock(fullCellId);
|
||||||
|
if (_centerLandblock != center
|
||||||
|
|| !_resident.Contains(center)
|
||||||
|
|| !_runtime.EntityObjects.Physics.Engine
|
||||||
|
.IsLandblockTerrainResident(center))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (fullCellId & 0xFFFFu) < 0x0100u
|
||||||
|
|| _runtime.EntityObjects.Physics.DataCache
|
||||||
|
.GetCellStruct(fullCellId) is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishOne(
|
||||||
|
uint landblockId,
|
||||||
|
Vector3 origin,
|
||||||
|
uint currentCellId,
|
||||||
|
bool required)
|
||||||
|
{
|
||||||
|
LoadedLandblock? source =
|
||||||
|
LandblockLoader.Load(_content.Dats, landblockId);
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
if (required)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"Required headless landblock 0x{landblockId:X8} is missing.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<WorldEntity> staticEntities =
|
||||||
|
LandblockPhysicsContentBuilder.HydrateStaticEntities(
|
||||||
|
_content.Dats,
|
||||||
|
source,
|
||||||
|
origin,
|
||||||
|
includeVisualBounds: false);
|
||||||
|
IReadOnlyList<WorldEntity> scenery =
|
||||||
|
LandblockPhysicsContentBuilder.HydrateProceduralScenery(
|
||||||
|
_content.Dats,
|
||||||
|
source,
|
||||||
|
origin,
|
||||||
|
_content.HeightTable.AsSpan(),
|
||||||
|
includeVisualBounds: false);
|
||||||
|
var entities = new List<WorldEntity>(
|
||||||
|
staticEntities.Count + scenery.Count);
|
||||||
|
entities.AddRange(staticEntities);
|
||||||
|
entities.AddRange(scenery);
|
||||||
|
PhysicsDatBundle dats =
|
||||||
|
LandblockPhysicsContentBuilder.BuildDatBundle(
|
||||||
|
_content.Dats,
|
||||||
|
landblockId,
|
||||||
|
entities);
|
||||||
|
var landblock = new LoadedLandblock(
|
||||||
|
source.LandblockId,
|
||||||
|
source.Heightmap,
|
||||||
|
entities,
|
||||||
|
dats);
|
||||||
|
LandblockCollisionBuild collisions =
|
||||||
|
LandblockPhysicsContentBuilder
|
||||||
|
.BuildPreparedCollisionClosure(
|
||||||
|
_content.PreparedCollision,
|
||||||
|
landblock);
|
||||||
|
|
||||||
|
RuntimePhysicsState physics =
|
||||||
|
_runtime.EntityObjects.Physics;
|
||||||
|
PhysicsDataCache cache = physics.DataCache;
|
||||||
|
TerrainSurface terrain =
|
||||||
|
LandblockPhysicsContentBuilder.BuildTerrainSurface(
|
||||||
|
landblock,
|
||||||
|
_content.HeightTable.AsSpan());
|
||||||
|
var cellSurfaces = new List<CellSurface>();
|
||||||
|
var portalPlanes = new List<PortalPlane>();
|
||||||
|
LandblockPhysicsContentBuilder.PublishPreparedCells(
|
||||||
|
cache,
|
||||||
|
landblock,
|
||||||
|
collisions,
|
||||||
|
origin,
|
||||||
|
cellSurfaces,
|
||||||
|
portalPlanes);
|
||||||
|
LandblockPhysicsContentBuilder.CacheBuildings(
|
||||||
|
cache,
|
||||||
|
landblock,
|
||||||
|
terrain,
|
||||||
|
origin);
|
||||||
|
LandblockPhysicsContentBuilder.CachePreparedObjects(
|
||||||
|
cache,
|
||||||
|
collisions);
|
||||||
|
|
||||||
|
RuntimeCollisionAdmission admission =
|
||||||
|
physics.BeginCollisionAdmission(landblockId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
physics.AdmitCollisionAssets(
|
||||||
|
admission,
|
||||||
|
new RuntimeLandblockCollisionAssets(
|
||||||
|
landblockId,
|
||||||
|
terrain,
|
||||||
|
cellSurfaces,
|
||||||
|
portalPlanes,
|
||||||
|
origin.X,
|
||||||
|
origin.Y,
|
||||||
|
currentCellId));
|
||||||
|
_ = LandblockPhysicsContentBuilder
|
||||||
|
.PublishStaticCollision(
|
||||||
|
physics.Engine,
|
||||||
|
cache,
|
||||||
|
landblock,
|
||||||
|
collisions,
|
||||||
|
origin);
|
||||||
|
_ = physics.CompleteCollisionAdmission(admission);
|
||||||
|
_resident.Add(CanonicalLandblock(landblockId));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_ = physics.WithdrawCollision(landblockId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RetireAll()
|
||||||
|
{
|
||||||
|
if (_resident.Count == 0)
|
||||||
|
{
|
||||||
|
_centerLandblock = 0u;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint[] retiring = [.. _resident];
|
||||||
|
_resident.Clear();
|
||||||
|
_centerLandblock = 0u;
|
||||||
|
foreach (uint landblock in retiring)
|
||||||
|
_ = _runtime.EntityObjects.Physics.WithdrawCollision(landblock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint CanonicalLandblock(uint fullCellId) =>
|
||||||
|
(fullCellId & 0xFFFF0000u) | 0xFFFFu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-window projection of canonical Runtime entity and transit state into
|
||||||
|
/// one local movement controller plus its per-session collision world.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeadlessSessionWorldProjection
|
||||||
|
: IRuntimeDirectWorldProjection
|
||||||
|
{
|
||||||
|
private const float DefaultRadius = 0.48f;
|
||||||
|
private const float DefaultHeight = 1.835f;
|
||||||
|
|
||||||
|
private readonly GameRuntime _runtime;
|
||||||
|
private readonly IHeadlessCollisionNeighborhood _collision;
|
||||||
|
private readonly IPreparedCollisionSource? _preparedCollision;
|
||||||
|
|
||||||
|
internal HeadlessSessionWorldProjection(
|
||||||
|
GameRuntime runtime,
|
||||||
|
HeadlessProcessContentOwner.HeadlessProcessContentLease content)
|
||||||
|
: this(
|
||||||
|
runtime,
|
||||||
|
new HeadlessCollisionNeighborhood(runtime, content),
|
||||||
|
content.PreparedCollision)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HeadlessSessionWorldProjection(
|
||||||
|
GameRuntime runtime,
|
||||||
|
IHeadlessCollisionNeighborhood collision,
|
||||||
|
IPreparedCollisionSource? preparedCollision = null)
|
||||||
|
{
|
||||||
|
_runtime = runtime
|
||||||
|
?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
_collision = collision
|
||||||
|
?? throw new ArgumentNullException(nameof(collision));
|
||||||
|
_preparedCollision = preparedCollision;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ProjectSpawn(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
bool isLocalPlayer)
|
||||||
|
{
|
||||||
|
if (isLocalPlayer)
|
||||||
|
SynchronizeLocalPlayer(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ProjectPosition(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
bool isLocalPlayer)
|
||||||
|
{
|
||||||
|
if (isLocalPlayer)
|
||||||
|
SynchronizeLocalPlayer(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginTeleport()
|
||||||
|
{
|
||||||
|
if (_runtime.MovementOwner.Controller is { } controller)
|
||||||
|
controller.State = PlayerState.PortalSpace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeDestinationReadiness PrepareDestination(
|
||||||
|
long revealGeneration,
|
||||||
|
RuntimeTeleportDestination destination)
|
||||||
|
{
|
||||||
|
_collision.CenterOn(destination.CellId);
|
||||||
|
if (_runtime.EntityObjects.Entities.TryGetActive(
|
||||||
|
destination.EntityGuid,
|
||||||
|
out RuntimeEntityRecord record))
|
||||||
|
{
|
||||||
|
SynchronizeLocalPlayer(record);
|
||||||
|
}
|
||||||
|
if (_runtime.MovementOwner.Controller is { } controller)
|
||||||
|
controller.State = PlayerState.InWorld;
|
||||||
|
|
||||||
|
bool ready = _collision.IsReady(destination.CellId);
|
||||||
|
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||||
|
return new RuntimeDestinationReadiness(
|
||||||
|
revealGeneration,
|
||||||
|
destination.CellId,
|
||||||
|
indoor,
|
||||||
|
IsUnhydratable: !ready,
|
||||||
|
RequiredRenderRadius: indoor ? 0 : 1,
|
||||||
|
IsRenderNeighborhoodReady: true,
|
||||||
|
AreCompositeTexturesReady: true,
|
||||||
|
IsCollisionReady: ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SynchronizeLocalPlayer(RuntimeEntityRecord record)
|
||||||
|
{
|
||||||
|
if (record.ServerGuid
|
||||||
|
!= _runtime.PlayerIdentity.ServerGuid
|
||||||
|
|| record.Snapshot.Position is not { } position)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_collision.CenterOn(position.LandblockId);
|
||||||
|
PlayerMovementController controller =
|
||||||
|
_runtime.MovementOwner.Controller
|
||||||
|
?? CreateController(record);
|
||||||
|
Vector3 wirePosition = new(
|
||||||
|
position.PositionX,
|
||||||
|
position.PositionY,
|
||||||
|
position.PositionZ);
|
||||||
|
Quaternion orientation = new(
|
||||||
|
position.RotationX,
|
||||||
|
position.RotationY,
|
||||||
|
position.RotationZ,
|
||||||
|
position.RotationW);
|
||||||
|
|
||||||
|
ResolveResult resolved =
|
||||||
|
_runtime.EntityObjects.Physics.Engine.Resolve(
|
||||||
|
wirePosition,
|
||||||
|
position.LandblockId,
|
||||||
|
Vector3.Zero,
|
||||||
|
100f);
|
||||||
|
ResolveResult placement =
|
||||||
|
_runtime.EntityObjects.Physics.Engine.ResolvePlacement(
|
||||||
|
resolved.Position,
|
||||||
|
resolved.CellId,
|
||||||
|
DefaultRadius,
|
||||||
|
DefaultHeight,
|
||||||
|
controller.StepUpHeight,
|
||||||
|
controller.StepDownHeight,
|
||||||
|
ObjectInfoState.IsPlayer
|
||||||
|
| ObjectInfoState.EdgeSlide,
|
||||||
|
record.LocalEntityId ?? 0u);
|
||||||
|
if (placement.Ok)
|
||||||
|
resolved = placement;
|
||||||
|
|
||||||
|
controller.LocalEntityId = record.LocalEntityId ?? 0u;
|
||||||
|
controller.SetPosition(
|
||||||
|
resolved.Position,
|
||||||
|
resolved.CellId,
|
||||||
|
wirePosition);
|
||||||
|
controller.SetBodyOrientation(orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayerMovementController CreateController(
|
||||||
|
RuntimeEntityRecord record)
|
||||||
|
{
|
||||||
|
var controller = new PlayerMovementController(
|
||||||
|
_runtime.EntityObjects.Physics.Engine,
|
||||||
|
record.ObjectClock,
|
||||||
|
PlayerMovementConstructionOptions.From(
|
||||||
|
_runtime.CharacterOwner.MovementSkills.Snapshot));
|
||||||
|
controller.ApplyPhysicsState(record.FinalPhysicsState);
|
||||||
|
controller.LocalEntityId = record.LocalEntityId ?? 0u;
|
||||||
|
ApplySetupStepHeights(record, controller);
|
||||||
|
RuntimeMovementSkillProjection.ApplyTo(
|
||||||
|
_runtime.CharacterOwner.MovementSkills,
|
||||||
|
controller);
|
||||||
|
_runtime.MovementOwner.Controller = controller;
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySetupStepHeights(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
PlayerMovementController controller)
|
||||||
|
{
|
||||||
|
if (record.Snapshot.SetupTableId is not { } setupId
|
||||||
|
|| (setupId & 0xFF000000u) != 0x02000000u
|
||||||
|
|| _preparedCollision is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PreparedCollisionReadResult<FlatSetupCollision> read =
|
||||||
|
_preparedCollision.ReadSetupCollision(setupId);
|
||||||
|
if (read.Status != PreparedAssetReadStatus.Loaded
|
||||||
|
|| read.Data is not { } setup)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"Player Setup collision 0x{setupId:X8} is {read.Status}.");
|
||||||
|
}
|
||||||
|
_runtime.EntityObjects.Physics.DataCache.CacheSetup(
|
||||||
|
setupId,
|
||||||
|
setup);
|
||||||
|
controller.StepUpHeight = setup.StepUpHeight > 0f
|
||||||
|
? setup.StepUpHeight
|
||||||
|
: 0.4f;
|
||||||
|
controller.StepDownHeight = setup.StepDownHeight > 0f
|
||||||
|
? setup.StepDownHeight
|
||||||
|
: 0.4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
Normal file
49
src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Headless.Configuration;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rejects process-global physics probes whose mutable capture cursors and
|
||||||
|
/// last-hit fields cannot be attributed safely when several Runtime roots
|
||||||
|
/// share one process. Ordinary diagnostics remain session-labelled through
|
||||||
|
/// <see cref="Diagnostics.HeadlessDiagnosticWriter"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static class HeadlessStaticStateAudit
|
||||||
|
{
|
||||||
|
internal static void ValidateProcessIsolation()
|
||||||
|
{
|
||||||
|
var enabled = new List<string>();
|
||||||
|
foreach (PropertyInfo property in typeof(PhysicsDiagnostics)
|
||||||
|
.GetProperties(BindingFlags.Public | BindingFlags.Static)
|
||||||
|
.OrderBy(static property => property.Name, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (property.PropertyType != typeof(bool)
|
||||||
|
|| property.GetMethod is null
|
||||||
|
|| (!property.Name.StartsWith(
|
||||||
|
"Probe",
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
&& !property.Name.StartsWith(
|
||||||
|
"Dump",
|
||||||
|
StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (property.GetValue(null) is true)
|
||||||
|
enabled.Add(property.Name);
|
||||||
|
}
|
||||||
|
if (PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
|
||||||
|
enabled.Add(nameof(PhysicsDiagnostics.CollisionShadowSampleEvery));
|
||||||
|
if (PhysicsResolveCapture.IsEnabled)
|
||||||
|
enabled.Add(nameof(PhysicsResolveCapture));
|
||||||
|
|
||||||
|
if (enabled.Count != 0)
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
"Multi-session headless mode cannot use process-global "
|
||||||
|
+ "physics probes. Disable: "
|
||||||
|
+ string.Join(", ", enabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,16 +252,21 @@ public sealed class RuntimeLiveEntitySessionController
|
||||||
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
|
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
|
||||||
|
|
||||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
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(
|
if (!transit.AcknowledgeDestinationReadiness(
|
||||||
new RuntimeDestinationReadiness(
|
readiness))
|
||||||
generation,
|
|
||||||
destination.CellId,
|
|
||||||
indoor,
|
|
||||||
IsUnhydratable: false,
|
|
||||||
RequiredRenderRadius: indoor ? 0 : 1,
|
|
||||||
IsRenderNeighborhoodReady: true,
|
|
||||||
AreCompositeTexturesReady: true,
|
|
||||||
IsCollisionReady: true)))
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Runtime rejected headless destination readiness.");
|
"Runtime rejected headless destination readiness.");
|
||||||
|
|
|
||||||
|
|
@ -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]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
376
tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs
Normal file
376
tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs
Normal 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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue