acdream/tests/AcDream.App.Tests/World/RuntimeForcePositionRenderCommitTests.cs
Erik f8e55ba5e4 fix(physics): route local-player shadow presentation through SyncPose (#318, AP-145)
RuntimePlacementPresentationSink.TryPublishPlace previously published the
local player's collision-shadow pose with a direct LocalPlayerShadowState.Set
call — a plain cache write that never touched PhysicsEngine.ShadowObjects.
Because LocalPlayerShadowSynchronizer.SyncPose's own dedup check compares
against that same cache, the direct write could pre-seed the cache with the
destination pose and cause the next real SyncPose call to see "nothing
changed" and skip its own ShadowObjects publish — leaving the real collision
shadow at the pre-teleport position until an unrelated movement tick forced
a real publish.

Fix: TryPublishPlace now calls _localPlayerShadowSync.SyncPose(...,
force: true), the same publisher ordinary per-tick movement uses, so Place
always drives a real ShadowObjects write before the cache updates.
TryPublishWithdrawal carried the exact mirror asymmetry (a bare
LocalPlayerShadowState.Clear with no ShadowObjects.Suspend, leaving a live
phantom shadow row at the park's source cell for the whole park window — the
#184 shape) and is fixed in the same commit, same one-call shape:
_localPlayerShadowSync.Suspend(entity). The sink no longer holds a direct
LocalPlayerShadowState reference; both halves route exclusively through the
one synchronizer, which owns the cache internally.

The single LocalPlayerShadowSynchronizer instance is now constructed in
LivePresentationComposition (before the sink) and threaded through
LivePresentationResult to SessionPlayerComposition, which no longer builds
its own — this guarantees the sink's Place/Withdraw edge and ordinary
per-tick movement publish through the exact same publisher and cache rather
than two independent instances that could drift out of sync with each other.

TryPublishPlace's xmldoc now states the behavioural nuance directly: routing
through SyncPose means Place inherits SyncPose's own admission guard
(IsHidden, cellId == 0, not-current-visible-projection), which the old
direct .Set() call never consulted. Under those conditions SyncPose now
calls Suspend instead of publishing — correct and symmetric, but new
behaviour worth flagging at the call site, not just in a test comment.

RuntimePlacementShadowCompositionTests.cs (#318) proves four facts against
the real ShadowObjects registry, not the cache: a bare Place publishes a
real row at the destination cell with the source cell's row gone; a
subsequent ordinary per-tick Sync is then a correct no-op; a Place for a
registered non-local-player entity leaves its row at the source cell
untouched and never touches the player's cache (route 7 P4 — the fix lives
entirely inside the pre-existing player-only gate); and Withdraw suspends
the real registry row, not just the cache, with the retained
(suspendable) registration surviving for a later restore. All four were
sabotage-verified in both directions.

RuntimeForcePositionRenderCommitTests.cs (B2) drives a real end-to-end
accepted ForcePosition through RuntimeEntityObjectLifetime.TryApplyPosition
and RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition
against a live HostFixture, asserting both the committed render position
AND a cell change that deliberately crosses out of the spawn's outdoor grid
cell, so the cell assertion is independently falsifiable rather than riding
along with the position assertion.

Retires AP-145 (this fix) in docs/architecture/retail-divergence-register.md.
AP-1 and AD-1 are untouched by this commit — they retire separately in the
deletion-sweep commit that follows.

Evidence chain: docs/research/2026-08-05-c5a-contract.md (the governing C5a
slice contract), docs/research/2026-08-05-c5a-architecture-review.md (round
1, FAIL — three MAJORs: vacuous route-7 P4 test, unfixed Withdraw-side
mirror asymmetry, non-driving B2 test), docs/research/2026-08-05-c5a-architecture-review-round2.md
(round 2, PASS with two MINORs — an unfalsifiable B2 cell assertion and the
undocumented SyncPose guard nuance, both fixed here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:09:11 +02:00

593 lines
23 KiB
C#

using System.Net;
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Content.Pak;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.World;
/// <summary>
/// C5a §5.2 (2026-08-05) — the route-2 B2 closure, corrected at the
/// architecture-review re-pass (A3). Route 2's review
/// (<c>docs/research/2026-08-03-c4-route-2-review-findings.md</c> B2) found:
/// "no test drives a route-2 ForcePosition through
/// <see cref="RuntimePlacementPresentationSink"/> /
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementPlace</c> and asserts the
/// <see cref="WorldEntity"/> moved."
///
/// <para>
/// <b>A3 correction:</b> the first version of this test hand-authored a
/// <c>RuntimePlacementProjectionSnapshot</c> and called <c>Sink.TryApply</c>
/// directly — it never drove a ForcePosition at all, and the five facts it
/// asserted were already pinned by
/// <c>RuntimePlacementPresentationSinkTests.Place_ReframesAndRebucketsExactSidecarWithoutMutatingRuntimePhysics</c>.
/// This version drives the REAL production path: a bare
/// <see cref="RuntimeEntityObjectLifetime"/> hosts a live local player
/// (<see cref="LiveEntityHydrationController"/> → the real
/// <see cref="AcDream.Runtime.Session.RuntimeFirstEntryDriveController"/>
/// pump, mirroring <c>RuntimeFirstEntryHostIntegrationTests</c>'s established
/// composition), then a REAL
/// <see cref="AcDream.Runtime.Session.RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition"/>
/// call — fed by the REAL <see cref="RuntimeEntityObjectLifetime.TryApplyPosition"/>
/// merge, exactly like <c>RuntimeAcceptedPositionDriveControllerTests.MergeAccepted</c>
/// — commits the canonical placement. The REAL
/// <see cref="AcDream.Runtime.Physics.RuntimePlacementProjectionSubscription"/>
/// (subscribed to the SAME <see cref="RuntimeEntityObjectLifetime"/>'s
/// placement channel) synchronously forwards the resulting receipt to the
/// REAL <see cref="RuntimePlacementPresentationSink"/>, which writes the
/// render <see cref="WorldEntity"/>. Nothing in this chain is hand-authored;
/// the receipt is the ForcePosition path's OWN output.
/// </para>
///
/// <para>
/// <b>Sabotage-verified (manual):</b> with the receipt→render write severed
/// (<c>RuntimePlacementPresentationSink.TryApply</c> short-circuited to
/// acknowledge-and-ignore a <c>Place</c> without calling
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementProjection</c>), the test
/// fails at exactly the position assertion (the entity stays at its
/// pre-force pose); restored, it is green.
/// </para>
/// </summary>
public sealed class RuntimeForcePositionRenderCommitTests
{
private const uint Cell = 0x01010001u;
private const uint PlayerGuid = 0x7000B201u;
// M1 (architecture review round 2, 2026-08-05): landblock-local (30,30)
// deliberately crosses OUT of the spawn's outdoor grid cell (cx=0,cy=0 ->
// low word 0x0001) into cx=1,cy=1 (TerrainSurface.CellSize=24) -> low
// word 0x000A, so ForcedCell != Cell and the cell assertion below is
// actually falsifiable. The prior (15,15) choice stayed inside the SAME
// grid cell as the spawn, so Assert.Equal(Cell, entity.ParentCellId)
// passed before the drive ever ran — a vacuous cell assertion the
// position assertion's own strength (wire Z=0 vs resolved Z=0.48) had
// been masking.
private static readonly Vector3 ForcedPosition = new(30f, 30f, 0.48f);
private const uint ForcedCell = 0x0101000Au;
[Fact]
public void AcceptedForcePosition_DrivenEndToEnd_MovesRenderEntityFromTheCommittedReceipt()
{
using var fixture = new HostFixture();
fixture.Controller.OnCreate(Spawn(PlayerGuid, Cell));
PlayerMovementController controller = Assert.IsType<PlayerMovementController>(
fixture.Movement.Controller);
Assert.True(fixture.Runtime.TryGetRecord(PlayerGuid, out LiveEntityRecord record));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
Vector3 positionBeforeForce = entity.Position;
// The accepted ForcePosition wire update — a genuine forced position
// and heading correction, admitted by the REAL PhysicsTimestampGate
// (fresh FORCE_POSITION_TS with an equal TELEPORT_TS, retail
// SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION
// branch) exactly like RuntimeAcceptedPositionDriveControllerTests
// drives it in Runtime.Tests.
WorldSession.EntityPositionUpdate wire = ForceUpdate(ForcedPosition);
Assert.True(fixture.EntityObjects.TryApplyPosition(
wire,
isLocalPlayer: true,
forcePositionRotation: controller.BodyOrientation,
currentLocalVelocity: controller.BodyVelocity,
acknowledgeProjection: null,
out PositionTimestampDisposition disposition,
out _,
out AcceptedPhysicsTimestamps timestamps));
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
RuntimeAcceptedPositionExecutionStatus status =
fixture.Drive.TryExecuteAcceptedLocalPosition(
record.Canonical,
wire,
disposition,
timestamps,
timestamps.PreviousTeleport);
// THE DISCRIMINATING ASSERTIONS. Nothing here is asserted from the
// wire update or a hand-authored snapshot — every value is read back
// from the render entity AFTER the real drive + real subscription +
// real sink chain ran.
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status);
Assert.NotEqual(positionBeforeForce, entity.Position);
Assert.Equal(ForcedPosition, entity.Position);
// M1 (architecture review round 2): ForcedCell genuinely differs
// from the spawn Cell — this assertion is independently
// falsifiable, verified by isolating it ahead of the position
// asserts under the same receipt->render sabotage: it fails on its
// own (entity.ParentCellId stays at the spawn Cell), not merely
// alongside the position assertion.
Assert.NotEqual(Cell, entity.ParentCellId);
Assert.Equal(ForcedCell, entity.ParentCellId);
Assert.True(record.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
}
private static WorldSession.EntityPositionUpdate ForceUpdate(
Vector3 position,
ushort positionSequence = 2,
ushort forcePositionSequence = 1) =>
new(
PlayerGuid,
new CreateObject.ServerPosition(
Cell,
position.X,
position.Y,
position.Z,
1f,
0f,
0f,
0f),
Velocity: null,
PlacementId: null,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: positionSequence,
TeleportSequence: 0,
ForcePositionSequence: forcePositionSequence);
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
{
var position = new CreateObject.ServerPosition(
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
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: 0x09000001u,
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,
"force-position fixture",
(uint)ItemType.Creature,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
/// <summary>
/// Mirrors <c>RuntimeFirstEntryHostIntegrationTests.HostFixture</c>'s
/// established composition (bare <see cref="RuntimeEntityObjectLifetime"/>
/// + REAL <see cref="RuntimePlacementProjectionSubscription"/> + REAL
/// <see cref="RuntimePlacementPresentationSink"/>), extended with a
/// <see cref="RuntimeAcceptedPositionDriveController"/> constructed
/// against the SAME entity objects, movement state, and generation —
/// exactly the shape <c>RuntimeAcceptedPositionDriveControllerTests
/// .CreateAcceptedPositionDrive</c> uses in Runtime.Tests, adapted to
/// the App-layer bare-lifetime pattern instead of a full
/// <c>GameRuntime</c>.
/// </summary>
private sealed class HostFixture : IDisposable
{
internal readonly RuntimeEntityObjectLifetime EntityObjects = new();
internal readonly LiveEntityRuntime Runtime;
internal readonly LiveEntityHydrationController Controller;
internal readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController
FirstEntry;
internal readonly RuntimeAcceptedPositionDriveController Drive;
internal readonly RuntimeLocalPlayerMovementState Movement;
internal readonly WorldGameState WorldState = new();
private readonly WorldSession _session;
internal HostFixture()
{
EntityObjects.BindEventContext(
static () => new RuntimeGenerationToken(1UL),
static () => 1UL);
EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
Cell & 0xFFFF0000u, 1UL);
EntityObjects.Physics.Engine.AddLandblock(
Cell & 0xFFFF0000u,
new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
Cell & 0xFFFF0000u, 1UL, ready: true);
EntityObjects.Physics.ObserveLocalWorldFrame(
Cell, teleportAdvanced: false);
Movement = new RuntimeLocalPlayerMovementState();
var runtimeIdentity = new RuntimeLocalPlayerIdentityState();
var publication = new RuntimeLocalPlayerPhysicsPublicationState(
EntityObjects.Entities,
EntityObjects.Physics,
Movement,
runtimeIdentity);
Movement.AttachPhysicsPublication(publication);
EntityObjects.LocalPlayerFirstEntry.BindPublication(publication);
runtimeIdentity.ServerGuid = PlayerGuid;
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
(Cell & 0xFFFF0000u) | 0xFFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
Runtime = new LiveEntityRuntime(
spatial,
new NoopResources(),
EntityObjects);
FirstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController(
EntityObjects,
new GameRuntimeClock(),
new UnusedCollisionSource(),
static () => PlayerMovementConstructionOptions.Fallback,
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
0.48f,
1.835f,
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
var localShadowState = new LocalPlayerShadowState();
var localShadowIdentity = new LocalPlayerIdentityState
{
ServerGuid = PlayerGuid,
};
var localShadowOrigin = new LiveWorldOriginState();
localShadowOrigin.SetPlaceholder(0, 0);
var localShadowSynchronizer = new LocalPlayerShadowSynchronizer(
EntityObjects.Physics.Engine,
Runtime,
localShadowIdentity,
localShadowOrigin,
localShadowState);
var sink = new RuntimePlacementPresentationSink(
Runtime,
new RuntimeWorldTransitState(),
WorldState,
new WorldEvents(),
new EntityEffectPoseRegistry(),
localShadowSynchronizer,
() => PlayerGuid,
_ => { },
[(_, _) => { }]);
_ = new AcDream.Runtime.Physics.RuntimePlacementProjectionSubscription(
EntityObjects.Placements,
static () => new RuntimeGenerationToken(1UL),
sink);
var materializer = new HostMaterializer(Runtime);
var identity = new LocalPlayerIdentityState { ServerGuid = PlayerGuid };
var dormant = new DormantLiveEntityStore();
var deletion = new LiveEntityDeletionController(
Runtime,
EntityObjects,
new NoopTeardown(),
identity,
dormant);
Controller = new LiveEntityHydrationController(
Runtime,
EntityObjects,
new object(),
materializer,
new NoopRelationships(),
new AcceptingReady(),
new KnownOrigin(),
new NoopNetworkSink(),
new NoopTimestamps(),
identity,
deletion,
dormant,
firstEntry: FirstEntry);
_session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
new FixtureTransport())
{
// Phase I.3 test seam: intercepts the outbound ack body
// before the wire-write path, which would otherwise NPE on
// an unseeded ISAAC keystream (this fixture never runs a
// real Connect() handshake). This test's subject is the
// canonical commit -> render entity chain, not the outbound
// ack itself.
GameActionCapture = _ => { },
};
Drive = new RuntimeAcceptedPositionDriveController(
EntityObjects,
new GameRuntimeClock(),
new UnusedCollisionSource(),
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
static () => new RuntimeGenerationToken(1UL),
static () => PlayerGuid,
() => Movement.Controller,
// usePositionFromServer: true (autonomy level 2) suppresses
// the outbound ack this test doesn't exercise — the fixture
// WorldSession never negotiates ISAAC (no real Connect()),
// so an attempted send would throw. This test's subject is
// the canonical commit -> render entity chain, not the
// outbound ack (that is AP-144's own separately-filed row).
static () => true,
() => _session);
}
public void Dispose()
{
_session.Dispose();
try
{
Runtime.Clear();
}
catch
{
// Failure-path assertions are made before Dispose runs.
}
}
}
private sealed class HostMaterializer(LiveEntityRuntime runtime)
: ILiveEntityProjectionMaterializer
{
public bool TryMaterialize(
RuntimeEntityRecord expectedCanonical,
WorldSession.EntitySpawn canonicalSpawn,
LiveProjectionPurpose purpose,
ulong expectedCreateIntegrationVersion,
AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null)
{
if (canonicalSpawn.Position is not { } position
|| canonicalSpawn.SetupTableId is null)
{
return false;
}
WorldEntity? entity = runtime.MaterializeLiveEntity(
expectedCanonical,
position.LandblockId,
id => new WorldEntity
{
Id = id,
ServerGuid = canonicalSpawn.Guid,
SourceGfxObjOrSetupId = canonicalSpawn.SetupTableId.Value,
Position = new Vector3(
position.PositionX,
position.PositionY,
position.PositionZ),
Rotation = Quaternion.Identity,
MeshRefs = [],
ParentCellId = position.LandblockId,
},
LiveEntityProjectionKind.World,
initializeProjection: null,
out LiveEntityRecord? record,
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
if (entity is null || record is null)
return false;
if (runtime.IsCurrentCreateIntegration(
expectedCanonical,
expectedCreateIntegrationVersion)
&& expectedCanonical.FullCellId != 0u
&& !runtime.HasActiveInitialCreateResidence(expectedCanonical)
&& !runtime.RebucketLiveEntity(
canonicalSpawn.Guid,
expectedCanonical.FullCellId))
{
return false;
}
return runtime.IsCurrentRecord(record);
}
public void ResetSessionState()
{
}
}
private sealed class NoopResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity)
{
}
public void Unregister(WorldEntity entity)
{
}
}
private sealed class NoopTeardown : ILiveEntityTeardownCoordinator
{
public void TearDown(LiveEntityRecord record)
{
}
public void ForgetUnknownOwner(uint serverGuid)
{
}
}
private sealed class NoopRelationships : ILiveEntityRelationshipProjection
{
public void OnSpawn(WorldSession.EntitySpawn spawn)
{
}
public void OnParent(ParentEvent.Parsed update)
{
}
public void OnCreateParentAccepted(CreateParentUpdate update)
{
}
public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) =>
ChildUnparentDisposition.Completed;
public bool TryApplyAttachedAppearance(
LiveEntityRecord record,
ulong objDescAuthorityVersion) => false;
}
private sealed class AcceptingReady : ILiveEntityReadyPublisher
{
public bool Publish(LiveEntityReadyCandidate candidate) => true;
}
private sealed class KnownOrigin : ILiveEntityWorldOriginCoordinator
{
public bool IsKnown => true;
public LiveEntityOriginInitialization TryInitialize(
WorldSession.EntitySpawn spawn) => new(true, []);
}
private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink
{
public void ApplySameGeneration(SameGenerationCreateObjectEvents events)
{
}
}
private sealed class NoopTimestamps : IAcceptedLocalPhysicsTimestampPublisher
{
public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps)
{
}
}
private sealed class UnusedCollisionSource : IPreparedCollisionSource
{
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId) => PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatSetupCollision> ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatSetupCollision>.Loaded(
new FlatSetupCollision(
System.Collections.Immutable.ImmutableArray<
FlatCollisionCylinder>.Empty,
[new FlatCollisionSphere(Vector3.Zero, 0.48f)],
height: 0f,
radius: 0f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f));
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public PreparedCollisionReadResult<FlatEnvCellTopology> ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public PreparedCollisionSourceStats CollisionStats => default;
public void 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()
{
}
}
}