refactor(world): canonicalize live physics host ownership

This commit is contained in:
Erik 2026-07-21 14:05:34 +02:00
parent 5882b308c1
commit fcb66198fc
21 changed files with 1546 additions and 323 deletions

View file

@ -242,7 +242,6 @@ public sealed class LiveSessionResetPlanTests
AnimationHookFrames = Stage("animation hook frames"),
LivePresentation = Stage("live presentation"),
RemoteMovementDiagnostics = Stage("remote movement diagnostics"),
PhysicsHostIndex = Stage("physics host index"),
});
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
@ -255,7 +254,6 @@ public sealed class LiveSessionResetPlanTests
.Select(static error => error.StageName));
Assert.Equal(playerA, sessionIdentity);
Assert.Equal(1, live.PendingTeardownCount);
Assert.False(dirty["physics host index"]);
Assert.Contains(staticEntity, spatial.Entities);
Assert.Equal(1, spatial.PersistentGuidCount);
@ -319,7 +317,6 @@ public sealed class LiveSessionResetPlanTests
"animation hook frames",
"live presentation",
"remote movement diagnostics",
"physics host index",
];
private static WorldEntity Entity(uint id, uint guid) => new()

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;

View file

@ -1202,7 +1202,10 @@ public sealed class ProjectileControllerTests
fixture.Live.SetRemoteMotionRuntime(Guid, replacement);
remote.CellId = CellB;
Assert.Equal(CellA, record.FullCellId);
Assert.Equal(0u, remote.CellId);
// A displaced component retains read-only access to its exact
// incarnation through exit_world; its guarded write cannot mutate the
// current canonical owner.
Assert.Equal(CellA, remote.CellId);
replacement.CellId = CellB;
Assert.Equal(CellB, record.FullCellId);
}

View file

@ -1,5 +1,5 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Physics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;

View file

@ -0,0 +1,745 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Tests.World;
public sealed class LiveEntityPhysicsHostOwnershipTests
{
[Fact]
public void Host_IsOwnedByExactRecordAndStaleRemoteReaderCannotSeeReplacement()
{
const uint guid = 0x70000010u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var firstMotion = new HostConsumerMotion();
runtime.SetRemoteMotionRuntime(guid, firstMotion);
EntityPhysicsHost firstHost = Host(guid);
runtime.InstallPhysicsHost(first, firstHost);
Assert.Same(firstHost, firstMotion.Host);
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
Assert.Same(firstHost, resolved);
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
var secondMotion = new HostConsumerMotion();
runtime.SetRemoteMotionRuntime(guid, secondMotion);
EntityPhysicsHost replacement = Host(guid);
runtime.InstallPhysicsHost(second, replacement);
Assert.Null(firstMotion.Host);
Assert.Same(replacement, secondMotion.Host);
Assert.Same(replacement, second.PhysicsHost);
}
[Fact]
public void Install_RejectsWrongGuidDuplicateAndStaleRecord()
{
const uint guid = 0x70000011u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
EntityPhysicsHost original = Host(guid);
runtime.InstallPhysicsHost(first, original);
Assert.Throws<ArgumentException>(() =>
runtime.InstallPhysicsHost(first, Host(guid + 1)));
Assert.Throws<InvalidOperationException>(() =>
runtime.InstallPhysicsHost(first, Host(guid)));
LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
Assert.Throws<InvalidOperationException>(() =>
runtime.InstallPhysicsHost(first, Host(guid)));
Assert.Null(second.PhysicsHost);
}
[Fact]
public void InstallOrRebind_PreservesHostManagersAndLiveTrackingState()
{
const uint watcherGuid = 0x70000020u;
const uint targetGuid = 0x70000021u;
var runtime = Runtime();
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost watcher = Host(
watcherGuid,
resolve: Resolve,
position: new Vector3(1f, 0f, 0f));
EntityPhysicsHost target = Host(
targetGuid,
resolve: Resolve,
position: new Vector3(4f, 0f, 0f));
runtime.InstallPhysicsHost(watcherRecord, watcher);
runtime.InstallPhysicsHost(targetRecord, target);
watcher.PositionManager.StickTo(targetGuid, 0.5f, 1f);
watcher.PositionManager.ConstrainTo(
new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
1f,
5f);
TargetManager targetManager = watcher.TargetManager;
PositionManager positionManager = watcher.PositionManager;
StickyManager sticky = Assert.IsType<StickyManager>(positionManager.Sticky);
ConstraintManager constraint = Assert.IsType<ConstraintManager>(positionManager.Constraint);
Assert.Equal(targetGuid, sticky.TargetId);
Assert.NotNull(watcher.TargetManager.TargetInfo);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
EntityPhysicsHost configured = Host(
watcherGuid,
resolve: Resolve,
position: new Vector3(9f, 0f, 0f));
EntityPhysicsHost rebound = EntityPhysicsHost.InstallOrRebind(
runtime,
watcherRecord,
configured);
Assert.Same(watcher, rebound);
Assert.Same(targetManager, rebound.TargetManager);
Assert.Same(positionManager, rebound.PositionManager);
Assert.Same(sticky, rebound.PositionManager.Sticky);
Assert.Same(constraint, rebound.PositionManager.Constraint);
Assert.Equal(targetGuid, rebound.PositionManager.GetStickyObjectId());
Assert.Equal(9f, rebound.Position.Frame.Origin.X);
Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(watcherGuid));
EntityPhysicsHost secondConfiguration = Host(
watcherGuid,
resolve: Resolve,
position: new Vector3(12f, 0f, 0f));
Assert.Same(
watcher,
EntityPhysicsHost.InstallOrRebind(runtime, watcherRecord, secondConfiguration));
Assert.Same(targetManager, watcher.TargetManager);
Assert.Same(positionManager, watcher.PositionManager);
Assert.Equal(12f, watcher.Position.Frame.Origin.X);
}
[Fact]
public void SameGuidReplacement_HostCallbacksRemainIncarnationLocal()
{
const uint guid = 0x70000022u;
var runtime = Runtime();
LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
int firstUpdates = 0;
int firstInterrupts = 0;
var firstHost = CallbackHost(
guid,
_ => firstUpdates++,
() => firstInterrupts++);
runtime.InstallPhysicsHost(first, firstHost);
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
int replacementUpdates = 0;
int replacementInterrupts = 0;
var replacementHost = CallbackHost(
guid,
_ => replacementUpdates++,
() => replacementInterrupts++);
runtime.InstallPhysicsHost(replacement, replacementHost);
firstHost.HandleUpdateTarget(default);
firstHost.InterruptCurrentMovement();
Assert.Equal(1, firstUpdates);
Assert.Equal(1, firstInterrupts);
Assert.Equal(0, replacementUpdates);
Assert.Equal(0, replacementInterrupts);
}
[Fact]
public void RemoteMotion_FullHostGateUsesCanonicalStableHost()
{
const uint guid = 0x70000030u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var motion = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, motion);
EntityPhysicsHost minimal = Host(guid);
runtime.InstallPhysicsHost(record, minimal);
Assert.Null(motion.Host);
motion.MarkFullPhysicsHostBound();
Assert.Same(minimal, motion.Host);
EntityPhysicsHost configured = Host(guid, position: new Vector3(7f, 0f, 0f));
Assert.Same(
minimal,
EntityPhysicsHost.InstallOrRebind(runtime, record, configured));
Assert.Same(minimal, motion.Host);
Assert.Equal(7f, motion.Host!.Position.Frame.Origin.X);
runtime.SetRemoteMotionRuntime(guid, motion);
Assert.Same(minimal, motion.Host);
}
[Fact]
public void RemoteMotionBindingFailure_DoesNotPublishPartialCanonicalState()
{
const uint guid = 0x70000031u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var failing = new ThrowingCellConsumerMotion();
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, failing));
Assert.Null(record.RemoteMotionRuntime);
Assert.Null(record.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
runtime.SetRemoteMotionRuntime(guid, failing);
Assert.Same(failing, record.RemoteMotionRuntime);
Assert.Same(failing.Body, record.PhysicsBody);
}
[Fact]
public void AlreadyBoundRemoteFromOldGeneration_CannotPoisonReplacement()
{
const uint guid = 0x70000032u;
var runtime = Runtime();
runtime.RegisterLiveEntity(Spawn(guid, 1));
var stale = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, stale);
LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, stale));
Assert.Null(replacement.RemoteMotionRuntime);
Assert.Null(replacement.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
var fresh = new RemoteMotion(new PhysicsBody());
runtime.SetRemoteMotionRuntime(guid, fresh);
Assert.Same(fresh, replacement.RemoteMotionRuntime);
}
[Fact]
public void SameRemoteRuntime_CannotChangeCanonicalBodyOnIdempotentBind()
{
const uint guid = 0x70000033u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var firstBody = new PhysicsBody();
var secondBody = new PhysicsBody();
var alternating = new AlternatingBodyMotion(firstBody, secondBody);
runtime.SetRemoteMotionRuntime(guid, alternating);
Assert.Same(firstBody, record.PhysicsBody);
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, alternating));
Assert.Same(firstBody, record.PhysicsBody);
Assert.Same(alternating, record.RemoteMotionRuntime);
Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var indexed));
Assert.Same(alternating, indexed);
}
[Fact]
public void IdempotentRemoteBind_ReadsBodyOnceAndMutatesOnlyCanonicalBody()
{
const uint guid = 0x70000036u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var canonical = new PhysicsBody();
var unrelated = new PhysicsBody();
PhysicsStateFlags unrelatedInitialState = unrelated.State;
var scripted = new SequenceBodyMotion(canonical, canonical, unrelated);
runtime.SetRemoteMotionRuntime(guid, scripted);
runtime.SetRemoteMotionRuntime(guid, scripted);
Assert.Same(canonical, record.PhysicsBody);
Assert.Equal(record.FinalPhysicsState, canonical.State);
Assert.Equal(unrelatedInitialState, unrelated.State);
Assert.Equal(2, scripted.ReadCount);
}
[Fact]
public void ReentrantBind_SameGuidReplacementPreventsOuterPublication()
{
const uint guid = 0x70000034u;
var runtime = Runtime();
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var motion = new CallbackCanonicalMotion(() =>
runtime.RegisterLiveEntity(Spawn(guid, 2)));
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, motion));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.Null(replacement.RemoteMotionRuntime);
Assert.Null(replacement.PhysicsBody);
Assert.Null(retired.RemoteMotionRuntime);
Assert.Null(retired.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
}
[Fact]
public void ReentrantBind_SessionClearPreventsOuterPublication()
{
const uint guid = 0x70000035u;
var runtime = Runtime();
LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
var motion = new CallbackCanonicalMotion(runtime.Clear);
Assert.Throws<InvalidOperationException>(() =>
runtime.SetRemoteMotionRuntime(guid, motion));
Assert.Equal(0, runtime.Count);
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Null(retired.RemoteMotionRuntime);
Assert.Null(retired.PhysicsBody);
Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _));
}
[Fact]
public void Delete_TeardownHostsRemainPeerResolvableUntilExitWorldConverges()
{
const uint leftGuid = 0x70000040u;
const uint rightGuid = 0x70000041u;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record => CleanPhysicsHost(record));
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost left = Host(leftGuid, Resolve, new Vector3(0f, 0f, 0f));
EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
runtime.InstallPhysicsHost(leftRecord, left);
runtime.InstallPhysicsHost(rightRecord, right);
left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(leftGuid, InstanceSequence: 1),
isLocalPlayer: false));
Assert.Null(leftRecord.PhysicsHost);
Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
Assert.Null(right.TargetManager.TargetInfo);
Assert.False(right.TargetManager.VoyeurTable?.ContainsKey(leftGuid) ?? false);
Assert.False(runtime.TryGetPhysicsHost(leftGuid, out _));
}
[Fact]
public void SessionClear_TeardownHostsResolveAcrossTombstones()
{
const uint leftGuid = 0x70000042u;
const uint rightGuid = 0x70000043u;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record => CleanPhysicsHost(record));
LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!;
LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost left = Host(leftGuid, Resolve, Vector3.Zero);
EntityPhysicsHost right = Host(rightGuid, Resolve, new Vector3(2f, 0f, 0f));
runtime.InstallPhysicsHost(leftRecord, left);
runtime.InstallPhysicsHost(rightRecord, right);
left.PositionManager.StickTo(rightGuid, 0.5f, 1f);
right.PositionManager.StickTo(leftGuid, 0.5f, 1f);
runtime.Clear();
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Null(leftRecord.PhysicsHost);
Assert.Null(rightRecord.PhysicsHost);
Assert.Equal(0u, left.PositionManager.GetStickyObjectId());
Assert.Equal(0u, right.PositionManager.GetStickyObjectId());
}
[Fact]
public void FailedTeardown_RetryCannotMutateReplacementRelationship()
{
const uint guid = 0x70000044u;
const uint targetGuid = 0x70000045u;
bool failOnce = true;
EntityPhysicsHost? replacementHost = null;
LiveEntityRecord? replacementRecord = null;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record =>
{
if (failOnce)
{
failOnce = false;
replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
replacementHost = Host(guid, Resolve);
runtime.InstallPhysicsHost(replacementRecord, replacementHost);
replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
throw new InvalidOperationException("fixture failure before old host cleanup");
}
CleanPhysicsHost(record);
});
LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!;
IPhysicsObjHost? InitialResolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost oldHost = Host(guid, InitialResolve);
EntityPhysicsHost targetHost = Host(targetGuid, InitialResolve, new Vector3(2f, 0f, 0f));
runtime.InstallPhysicsHost(oldRecord, oldHost);
runtime.InstallPhysicsHost(targetRecord, targetHost);
oldHost.PositionManager.StickTo(targetGuid, 0.5f, 1f);
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
Assert.Same(oldHost, oldRecord.PhysicsHost);
Assert.Same(replacementHost, replacementRecord!.PhysicsHost);
Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId());
Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
Assert.Equal(1, runtime.RetryPendingTeardowns());
Assert.Null(oldRecord.PhysicsHost);
Assert.Same(replacementHost, replacementRecord.PhysicsHost);
Assert.Equal(targetGuid, replacementHost.PositionManager.GetStickyObjectId());
Assert.True(targetHost.TargetManager.VoyeurTable!.ContainsKey(guid));
}
[Fact]
public void FailedTeardown_TombstoneCannotAcceptNewRelationshipsOutsideTeardown()
{
const uint retiredGuid = 0x70000046u;
const uint watcherGuid = 0x70000047u;
bool fail = true;
LiveEntityRuntime runtime = null!;
runtime = Runtime(record =>
{
CleanPhysicsHost(record);
if (fail)
{
fail = false;
throw new InvalidOperationException("fixture post-exit failure");
}
});
LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!;
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host)
? host
: null;
EntityPhysicsHost retired = Host(retiredGuid, Resolve);
EntityPhysicsHost watcher = Host(watcherGuid, Resolve);
runtime.InstallPhysicsHost(retiredRecord, retired);
runtime.InstallPhysicsHost(watcherRecord, watcher);
Assert.Throws<AggregateException>(() => runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(retiredGuid, InstanceSequence: 1),
isLocalPlayer: false));
watcher.SetTarget(0, retiredGuid, 0.5f, 0.5);
Assert.Null(retired.TargetManager.VoyeurTable);
Assert.Equal(TargetStatus.Undefined, watcher.TargetManager.TargetInfo!.Value.Status);
Assert.False(runtime.TryGetPhysicsHost(retiredGuid, out _));
Assert.Equal(1, runtime.RetryPendingTeardowns());
}
[Fact]
public void MinimalHost_ExitPositionRetainsExactRecordFrame()
{
const uint guid = 0x70000048u;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
record.FullCellId = 0x01010123u;
record.WorldEntity = new AcDream.Core.World.WorldEntity
{
Id = 1_000_123u,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(12f, 34f, 56f),
Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f),
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
};
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
Assert.Equal(record.FullCellId, host.Position.ObjCellId);
Assert.Equal(record.WorldEntity.Position, host.Position.Frame.Origin);
Assert.Equal(record.WorldEntity.Rotation, host.Position.Frame.Orientation);
}
[Fact]
public void FullHost_ExitSnapshotRetainsOldFrameAcrossSameGuidReplacement()
{
const uint departingGuid = 0x70000049u;
const uint watcherGuid = 0x7000004Au;
var delivered = new List<TargetInfo>();
LiveEntityRuntime runtime = null!;
runtime = Runtime(record =>
{
if (record.ServerGuid == departingGuid)
{
LiveEntityRecord replacement = runtime.RegisterLiveEntity(
Spawn(departingGuid, 2)).Record!;
replacement.FullCellId = 0x02020202u;
replacement.WorldEntity = Entity(
departingGuid,
new Vector3(999f, 999f, 999f),
Quaternion.Identity);
runtime.InstallPhysicsHost(replacement, Host(departingGuid));
}
CleanPhysicsHost(record);
});
LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!;
LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!;
departing.FullCellId = 0x01010123u;
Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
departing.WorldEntity = Entity(
departingGuid,
new Vector3(12f, 34f, 56f),
departingRotation);
watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity);
IPhysicsObjHost? Resolve(uint id) =>
runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null;
var body = new PhysicsBody
{
Position = departing.WorldEntity.Position,
Orientation = departingRotation,
};
var remote = new RemoteMotion(body);
runtime.SetRemoteMotionRuntime(departingGuid, remote);
var departingHost = new EntityPhysicsHost(
departingGuid,
getPosition: () => new Position(
departing.FullCellId,
departing.WorldEntity?.Position ?? body.Position,
body.Orientation),
getVelocity: () => body.Velocity,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: Resolve,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
var watcher = new EntityPhysicsHost(
watcherGuid,
getPosition: () => new Position(0x01010001u, Vector3.Zero, Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: Resolve,
handleUpdateTarget: delivered.Add,
interruptCurrentMovement: () => { });
runtime.InstallPhysicsHost(departing, departingHost);
runtime.InstallPhysicsHost(watcherRecord, watcher);
watcher.SetTarget(0, departingGuid, 0.5f, 0.5);
delivered.Clear();
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(departingGuid, InstanceSequence: 1),
isLocalPlayer: false));
TargetInfo exit = Assert.Single(delivered);
Assert.Equal(TargetStatus.ExitWorld, exit.Status);
Assert.Equal(0x01010123u, exit.TargetPosition.ObjCellId);
Assert.Equal(new Vector3(12f, 34f, 56f), exit.TargetPosition.Frame.Origin);
Assert.Equal(departingRotation, exit.TargetPosition.Frame.Orientation);
Assert.Equal(0x01010123u, remote.CellId);
}
[Fact]
public void ActiveNonVisibleRecord_CanOwnResolvablePhysicsHost()
{
const uint guid = 0x7000004Bu;
var runtime = Runtime();
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!;
EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0);
runtime.InstallPhysicsHost(record, host);
Assert.Empty(runtime.WorldEntities);
Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved));
Assert.Same(host, resolved);
}
private static void CleanPhysicsHost(LiveEntityRecord record)
{
if (record.PhysicsHost is not EntityPhysicsHost host)
return;
host.PositionManager.UnStick();
host.ClearTarget();
host.NotifyExitWorld();
}
private static LiveEntityRuntime Runtime(Action<LiveEntityRecord>? teardown = null) =>
teardown is null
? new LiveEntityRuntime(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }))
: new LiveEntityRuntime(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
teardown);
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) =>
new(
Guid: guid,
Position: null,
SetupTableId: null,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "fixture",
ItemType: null,
MotionState: null,
MotionTableId: null,
InstanceSequence: instance);
private static AcDream.Core.World.WorldEntity Entity(
uint guid,
Vector3 position,
Quaternion rotation) =>
new()
{
Id = 1_000_000u + (guid & 0xFFFFu),
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = rotation,
MeshRefs = Array.Empty<AcDream.Core.World.MeshRef>(),
};
private static EntityPhysicsHost Host(
uint guid,
Func<uint, IPhysicsObjHost?>? resolve = null,
Vector3? position = null) =>
new(
guid,
getPosition: () => new Position(
0x01010001u,
position ?? Vector3.Zero,
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: resolve ?? (_ => null),
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
private static EntityPhysicsHost CallbackHost(
uint guid,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement) =>
new(
guid,
getPosition: () => new Position(
0x01010001u,
Vector3.Zero,
Quaternion.Identity),
getVelocity: () => Vector3.Zero,
getRadius: () => 0.5f,
inContact: () => true,
minterpMaxSpeed: () => 1f,
curTime: () => 0,
physicsTimerTime: () => 0,
getObjectA: _ => null,
handleUpdateTarget,
interruptCurrentMovement);
private sealed class HostConsumerMotion :
ILiveEntityRemoteMotionRuntime,
ILiveEntityPhysicsHostConsumer
{
private Func<IPhysicsObjHost?>? _read;
public PhysicsBody Body { get; } = new();
public IPhysicsObjHost? Host => _read?.Invoke();
public void BindPhysicsHost(Func<IPhysicsObjHost?> read)
{
if (_read is not null)
throw new InvalidOperationException("already bound");
_read = read;
}
}
private sealed class ThrowingCellConsumerMotion :
ILiveEntityRemoteMotionRuntime,
ILiveEntityCanonicalRuntimeConsumer
{
private bool _failOnce = true;
public PhysicsBody Body { get; } = new();
public void BindCanonicalRuntime(
Func<IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell)
{
ArgumentNullException.ThrowIfNull(readPhysicsHost);
ArgumentNullException.ThrowIfNull(readCell);
ArgumentNullException.ThrowIfNull(writeCell);
if (_failOnce)
{
_failOnce = false;
throw new InvalidOperationException("fixture context bind failure");
}
}
}
private sealed class AlternatingBodyMotion(
PhysicsBody first,
PhysicsBody second) : ILiveEntityRemoteMotionRuntime
{
private int _reads;
public PhysicsBody Body => _reads++ == 0 ? first : second;
}
private sealed class SequenceBodyMotion(params PhysicsBody[] bodies) :
ILiveEntityRemoteMotionRuntime
{
public int ReadCount { get; private set; }
public PhysicsBody Body
{
get
{
int index = Math.Min(ReadCount, bodies.Length - 1);
ReadCount++;
return bodies[index];
}
}
}
private sealed class CallbackCanonicalMotion(Action callback) :
ILiveEntityRemoteMotionRuntime,
ILiveEntityCanonicalRuntimeConsumer
{
public PhysicsBody Body { get; } = new();
public void BindCanonicalRuntime(
Func<IPhysicsObjHost?> readPhysicsHost,
Func<uint> readCell,
Action<uint> writeCell)
{
ArgumentNullException.ThrowIfNull(readPhysicsHost);
ArgumentNullException.ThrowIfNull(readCell);
ArgumentNullException.ThrowIfNull(writeCell);
callback();
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;

View file

@ -62,6 +62,9 @@ internal sealed class R5Host : IPhysicsObjHost
public IPhysicsObjHost? GetObjectA(uint id)
=> World.TryGetValue(id, out var h) && h.Resolvable ? h : null;
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
_targetManager?.GetRelationshipTarget(objectId);
public void HandleUpdateTarget(TargetInfo info)
{
HandleUpdateTargetCalls.Add(info);
@ -79,12 +82,14 @@ internal sealed class R5Host : IPhysicsObjHost
public void ClearTarget() => _targetManager?.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info);
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
_targetManager?.ReceiveUpdate(info, sender);
public void AddVoyeur(uint watcherId, float radius, double quantum)
=> TargetManager.AddVoyeur(watcherId, radius, quantum);
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
=> TargetManager.AddVoyeur(watcher, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId);
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
_targetManager?.RemoveVoyeur(watcherId, expectedWatcher);
// ── test helpers ───────────────────────────────────────────────────────
public void SetOrigin(Vector3 origin)

View file

@ -352,6 +352,8 @@ internal sealed class RemoteChaseHarness
public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id)
=> id == PlayerGuid ? _h._playerHost : null;
public IPhysicsObjHost? GetRelationshipTarget(uint objectId)
=> GetObjectA(objectId);
public void HandleUpdateTarget(TargetInfo info)
{
@ -373,9 +375,9 @@ internal sealed class RemoteChaseHarness
}
public void ClearTarget() => _h._targetArmed = false;
public void ReceiveTargetUpdate(TargetInfo info) { }
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId) { }
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
}
private sealed class TargetHost : IPhysicsObjHost
@ -391,13 +393,14 @@ internal sealed class RemoteChaseHarness
public double CurTime => _h.Now;
public double PhysicsTimerTime => _h.Now;
public IPhysicsObjHost? GetObjectA(uint id) => null;
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) => null;
public void HandleUpdateTarget(TargetInfo info) { }
public void InterruptCurrentMovement() { }
public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { }
public void ClearTarget() { }
public void ReceiveTargetUpdate(TargetInfo info) { }
public void AddVoyeur(uint watcherId, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId) { }
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) { }
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum) { }
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) { }
}
// ── The per-tick pipeline (GameWindow.TickAnimations order) ────────────

View file

@ -218,11 +218,11 @@ public sealed class StickyManagerTests
var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f };
var target = new R5Host(20u, world);
var sticky = new StickyManager(self);
target.Resolvable = false;
sticky.StickTo(target.Id, 0.5f, 1.0f);
// Cache a position via HandleUpdateTarget, then make the target vanish.
// Cache a position without ever establishing an exact target token.
var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity);
sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
target.Resolvable = false; // GetObjectA(target) → null → fall back to cached
var frame = new MotionDeltaFrame();
sticky.AdjustOffset(frame, quantum: 0.1);

View file

@ -76,7 +76,9 @@ public sealed class TargetManagerTests
int before = self.HandleUpdateTargetCalls.Count;
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p));
self.TargetManager.ReceiveUpdate(
new TargetInfo(999u, TargetStatus.Ok, p, p),
target);
Assert.Equal(before, self.HandleUpdateTargetCalls.Count);
}
@ -88,7 +90,9 @@ public sealed class TargetManagerTests
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var p = new Position(1u, Vector3.Zero, Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p));
self.TargetManager.ReceiveUpdate(
new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p),
target);
Assert.Null(self.TargetManager.TargetInfo); // cleared
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed
@ -102,7 +106,9 @@ public sealed class TargetManagerTests
self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity);
self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp));
self.TargetManager.ReceiveUpdate(
new TargetInfo(target.Id, TargetStatus.Ok, tp, tp),
target);
// self→target interp position (0,5,0) normalized = +Y.
var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading;
@ -110,6 +116,46 @@ public sealed class TargetManagerTests
Assert.Equal(1f, heading.Y, 3);
}
[Fact]
public void ReceiveUpdate_SameGuidReplacementSender_IsIgnored()
{
var (self, originalTarget, world) = TwoHosts();
self.TargetManager.SetTarget(0, originalTarget.Id, 1.0f, 0.0);
self.HandleUpdateTargetCalls.Clear();
var replacement = new R5Host(originalTarget.Id, world);
var p = new Position(1u, new Vector3(99f, 0f, 0f), Quaternion.Identity);
self.ReceiveTargetUpdate(
new TargetInfo(originalTarget.Id, TargetStatus.ExitWorld, p, p),
replacement);
Assert.Empty(self.HandleUpdateTargetCalls);
Assert.NotNull(self.TargetManager.TargetInfo);
Assert.Same(
originalTarget,
self.TargetManager.GetRelationshipTarget(originalTarget.Id));
}
[Fact]
public void StickyAdjustOffset_UsesExactTargetInsteadOfSameGuidReplacement()
{
var (self, originalTarget, world) = TwoHosts();
self.SetOrigin(Vector3.Zero);
originalTarget.SetOrigin(new Vector3(5f, 0f, 0f));
self.PositionManager.StickTo(originalTarget.Id, 0.5f, 1f);
var replacement = new R5Host(originalTarget.Id, world);
replacement.SetOrigin(new Vector3(10f, 0f, 0f));
originalTarget.SetOrigin(new Vector3(-5f, 0f, 0f));
var delta = new MotionDeltaFrame();
self.PositionManager.AdjustOffset(delta, 0.1);
Assert.True(delta.Origin.X < 0f);
Assert.Same(
originalTarget,
self.TargetManager.GetRelationshipTarget(originalTarget.Id));
}
[Fact]
public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds()
{
@ -133,9 +179,8 @@ public sealed class TargetManagerTests
public void HandleTargetting_Throttles_Within500ms()
{
var (self, target, _) = TwoHosts();
var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0);
// seed a voyeur directly + advance so a sweep WOULD send if not throttled.
target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0);
target.TargetManager.AddVoyeur(self, 0.1f, 0.0);
target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius
target.PhysicsTimerTime = 0.3; // < 0.5 throttle
@ -186,12 +231,12 @@ public sealed class TargetManagerTests
{
var (self, target, _) = TwoHosts();
target.SetOrigin(Vector3.Zero);
target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0);
target.TargetManager.AddVoyeur(self, radius: 1.0f, quantum: 0.0);
var voyeur = target.TargetManager.VoyeurTable![self.Id];
Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin);
target.SetOrigin(new Vector3(5f, 0f, 0f));
target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only
target.TargetManager.AddVoyeur(self, radius: 2.5f, quantum: 0.3); // existing → update only
Assert.Equal(2.5f, voyeur.Radius);
Assert.Equal(0.3, voyeur.Quantum);
@ -202,10 +247,27 @@ public sealed class TargetManagerTests
public void RemoveVoyeur_RemovesEntry()
{
var (self, target, _) = TwoHosts();
target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0);
Assert.True(target.TargetManager.RemoveVoyeur(self.Id));
target.TargetManager.AddVoyeur(self, 1.0f, 0.0);
Assert.True(target.TargetManager.RemoveVoyeur(self.Id, self));
Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id));
Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone
Assert.False(target.TargetManager.RemoveVoyeur(self.Id, self)); // already gone
}
[Fact]
public void HiddenWatcher_GuidReuseCannotRedirectOrRemoveExactSubscription()
{
var (watcher, target, world) = TwoHosts();
watcher.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0);
watcher.HandleUpdateTargetCalls.Clear();
watcher.Resolvable = false;
var replacement = new R5Host(watcher.Id, world);
target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
Assert.Single(watcher.HandleUpdateTargetCalls);
Assert.Empty(replacement.HandleUpdateTargetCalls);
Assert.False(target.TargetManager.RemoveVoyeur(watcher.Id, replacement));
Assert.True(target.TargetManager.RemoveVoyeur(watcher.Id, watcher));
}
[Fact]