feat(physics): C4 route 2 — ForcePosition through the canonical placement

A local-player ForcePosition had TWO independent writers for one accepted
packet: LocalForcePositionTransaction snapped the physics body
(PlayerMovementController.BlipPosition, a raw SnapToCell with no collision
resolve), while LiveEntityNetworkUpdateController's generic tail separately
wrote position/cell/rotation to the render WorldEntity from the raw wire and
rebucketed it. Two stores, one packet — the divergence class 670f307c fixed on
the remote path. The outbound AutonomousPosition ack also fired BEFORE any
canonical commit existed: we told ACE "got it, I'm here" before deciding where
"here" was, and the trailing isCurrent() could only suppress the continuation,
never recall the packet.

RuntimeAcceptedPositionDriveController is now the one Runtime-owned seam. Both
hosts call the identical TryExecuteAcceptedLocalPosition; App and headless
project the committed result through the existing placement projection sink
(LiveEntityRuntime.TryApplyRuntimePlacementPlace already performed the same
four writes, from committed state rather than a wire guess).

Retail: SmartBox::HandleReceivedPosition @0x00453FD0's FORCE_POSITION branch is
get_heading -> Frame::set_heading -> SmartBox::BlipPlayer @0x00453940 -> stamp
POSITION_TS -> SendPositionEvent @0x00454091 -> return @0x0045409D. BlipPlayer
is CPhysicsObj::SetPositionSimple @0x005162B0 with flags 0x1012
(Teleport|Slide|SendPositionEvent) — a real collision-resolving SetPosition,
not a snap. The pinned classifier already encoded this exactly.

Named behaviour changes:

* The ack is now an OUTPUT of the committed route, fired strictly after the
  canonical commit and exactly once per accepted force packet.
* The ForcePosition route no longer re-arms the constraint leash. The force
  branch returns at 0x0045409D, ahead of all three ConstrainTo sites
  (0x00454272, 0x0045418A, 0x004541EC); the old re-arm cited retail's "Player,
  normal" branch, which BlipPlayer is not on. The teleport, CommitPreparedPosition
  and first-entry callers legitimately still constrain and are untouched.
* A force correction that terminates WITHOUT committing still sends its
  position event and is not retried — retail's BlipPlayer discards
  SetPositionSimple's SetPositionError return and acks unconditionally.

A single _pending funnel owns the in-flight placement, deciding on the token's
PositionAuthorityVersion against the record's: equal -> clear; advanced with the
newest accepted event still a force -> re-issue, re-classified; advanced to an
ordinary Apply -> clear, since newer server truth owns that pose. This closes a
double-apply/double-ack and a silently-dropped correction that two earlier
iterations of this slice each introduced.

AD-62 records the residual: a ForcePosition our async collision publication
cannot carry to a committed placement is not re-applied. Retail has no park —
its world is fully resident and its placement synchronous — so the state is
unreachable there. AP-131 is NOT retired; its legacy Position caller is route 4.

Deleted: LocalForcePositionTransaction, PlayerMovementController.BlipPosition,
HeadlessSessionWorldProjection.BlipLocalPlayer.

Gates: complete Release solution 10,858 passed / 4 skipped / 0 failed (baseline
10,844/4/0). Two independent Opus reviews (retail-conformance and
architecture/adversarial) PASS on the final diff after three FAIL rounds; every
intermediate state was fully green, so the suite caught none of the four real
defects. Connected acceptance is NOT run: nothing a user can do makes ACE emit
a ForcePosition without retail's @pklite, which acdream does not implement — see
docs/research/2026-08-03-c4-route-2-visual-gate.md.

Known gap, recorded not claimed: the plan's acceptance item 2 is unmet. The App
double-write check is a source pin, and "the committed projection moves the
render entity" is uncovered at any layer (#292). Filed alongside: #286-#291,
#293-#296.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 18:46:36 +02:00
parent 22a5c95400
commit 9966b53174
25 changed files with 4292 additions and 195 deletions

View file

@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using AcDream.App.Physics;
namespace AcDream.App.Tests.Physics;
@ -46,51 +47,97 @@ public sealed class LiveEntityNetworkBranchRoutingTests
Assert.Equal(["projectile", "canonical", "ordinary"], calls);
}
[Fact]
public void ForcePosition_BlipsAndAcknowledgesExactlyOnce()
// C4 route 2 (2026-08-03): LocalForcePositionTransaction and its
// ForcePosition_* coverage here are RETIRED, not adapted — the class is
// deleted outright (docs/research/2026-08-03-c4-route-2-contract.md
// §"The contract" item 2). Its three jobs (ownership validation, the
// blip/commit, and the exactly-once ack including the displaced-
// authority case its trailing isCurrent() covered) are now properties of
// the Runtime-owned RuntimeAcceptedPositionDriveController and are tested
// there: tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs.
/// <summary>
/// R8 review fix (2026-08-03): source pins for the generic-tail
/// double-write guard in <c>LiveEntityNetworkUpdateController.OnPosition</c>.
/// A full behavioral fixture is impractical here for the SAME reason
/// <c>C3cF1ProductionWiringTests</c> gives — the controller's dependency
/// set is composition-only (67+ collaborators wired only by
/// <c>SessionPlayerComposition</c>) — so this follows that file's exact
/// established pattern: assert the STRUCTURE of the production source
/// rather than construct the class. The Runtime-level behavioral
/// coverage for the seam itself lives in
/// RuntimeAcceptedPositionDriveControllerTests; these pins are what stop
/// this App-layer call site from silently reintroducing the retired
/// duplicate-write authority (the deleted LocalForcePositionTransaction
/// pair + the generic render-tail writing the SAME accepted Position a
/// second time — 670f307c's divergence class).
/// </summary>
public sealed class LiveEntityNetworkUpdateControllerForcePositionWiringTests
{
int currentChecks = 0;
int blips = 0;
int acknowledgements = 0;
[Fact]
public void LocalForcePositionTransactionIsNeverCalledFromThisFile()
{
string source = ReadSource("LiveEntityNetworkUpdateController.cs");
bool completed = LocalForcePositionTransaction.Apply(
isForcePosition: true,
() => { currentChecks++; return true; },
() => blips++,
() => acknowledgements++);
// The name may still appear in a comment explaining what
// replaced it (contract §"the deleted LocalForcePositionTransaction");
// what must be gone is any actual call into it.
Assert.DoesNotContain(
"LocalForcePositionTransaction.Apply(",
source,
StringComparison.Ordinal);
}
Assert.True(completed);
Assert.Equal(2, currentChecks);
Assert.Equal(1, blips);
Assert.Equal(1, acknowledgements);
}
[Fact]
public void GenericTailWriteIsNeverDuplicatedForTheLocalForcePositionPath()
{
string source = ReadSource("LiveEntityNetworkUpdateController.cs");
[Fact]
public void ForcePosition_AcknowledgementInvalidationStopsTheTail()
{
bool current = true;
int acknowledgements = 0;
// The generic render-tail's WorldEntity write is the ONE
// remaining writer of an accepted Position — it must serve
// remotes only, never a second local-player write alongside the
// Runtime-committed one.
Assert.Single(
Regex.Matches(source, @"entity\.SetPosition\(worldPos\);")
.Cast<Match>());
}
bool completed = LocalForcePositionTransaction.Apply(
isForcePosition: true,
() => current,
() => { },
() => { acknowledgements++; current = false; });
[Fact]
public void CommittedOrDeferredCellReturnsBeforeReachingTheGenericTail()
{
string source = ReadSource("LiveEntityNetworkUpdateController.cs");
Assert.False(completed);
Assert.Equal(1, acknowledgements);
}
// The Committed/DeferredCell branch must still return
// immediately after its two preserved side effects — a missing
// `return` here would fall through into the generic tail below
// and resurrect the double-write.
Assert.Matches(
new Regex(
@"ObserveAcceptedLocalPosition\(\s*"
+ @"update\.Position\.LandblockId\);\s*return;",
RegexOptions.Singleline),
source);
}
[Fact]
public void OrdinaryPositionDoesNotBlipOrAcknowledge()
{
int calls = 0;
private static string ReadSource(string fileName)
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
return File.ReadAllText(Path.Combine(
directory.FullName,
"src",
"AcDream.App",
"Physics",
fileName));
}
Assert.True(LocalForcePositionTransaction.Apply(
isForcePosition: false,
() => { calls++; return false; },
() => calls++,
() => calls++));
Assert.Equal(0, calls);
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
}
}
}

View file

@ -393,16 +393,56 @@ public sealed class HeadlessSessionHostTests
acknowledgeProjection: null,
out PositionTimestampDisposition disposition,
out _,
out _));
out AcceptedPhysicsTimestamps timestamps));
Assert.Equal(
PositionTimestampDisposition.ForcePosition,
disposition);
projection.ProjectPosition(
record,
isLocalPlayer: true,
disposition);
Assert.Equal(new Vector3(72f, 73f, 50f), controller.Position);
// C4 route 2 (2026-08-03): a ForcePosition on the local player no
// longer routes through HeadlessSessionWorldProjection.ProjectPosition
// at all — RuntimeLiveEntitySessionController.OnPositionUpdated
// dispatches it directly to RuntimeAcceptedPositionDriveController
// instead (the deleted BlipLocalPlayer's replacement), but R2 review
// fix (2026-08-03): it re-centers the collision neighborhood on the
// destination FIRST — the deleted BlipLocalPlayer's own CenterOn
// side effect, restored via CenterOnAcceptedForcePosition, because a
// DeferredCell park this neighborhood's window can never publish is
// a dead end, not a real park
// (RuntimeAcceptedPositionDriveController.Advance's R1 doc comment).
projection.CenterOnAcceptedForcePosition(record);
RuntimeAcceptedPositionDriveController acceptedPositionDrive =
CreateAcceptedPositionDrive(runtime);
RuntimeAcceptedPositionExecutionStatus forceStatus =
acceptedPositionDrive.TryExecuteAcceptedLocalPosition(
record,
force,
disposition,
timestamps,
timestamps.PreviousTeleport);
Assert.Equal(
RuntimeAcceptedPositionExecutionStatus.Committed,
forceStatus);
// R7 review fix (2026-08-03): Z is 50.005f — within 5 mm of the
// wire's bare 50f — NOT 50.48f. The dat-exact human Setup's foot
// sphere is (0,0,0.475) r=.48 (LoadedSetupCollisionSource above);
// its bottom sits at origin + 0.475 0.48 = origin 0.005, so a
// settled origin lands 0.005 m ABOVE the floor it rests on (measured
// empirically against this exact fixture), not a full sphere RADIUS
// above it. Retail's BlipPlayer (CPhysicsObj::SetPositionSimple
// @0x005162B0, called from SmartBox::BlipPlayer @0x00453940) has
// never lifted the origin by a sphere radius — the C4-route-2 FIRST
// implementation pass (uncommitted; this file asserts a bare 50f at
// HEAD, never 50.48f) had fitted a 50.48f assertion to a dummy
// fixture sphere whose offset happened to equal its own radius, not
// to retail behavior. The comment it carried described the sphere's
// CENTRE, then wrongly asserted that description about
// controller.Position, which is the body's ORIGIN
// (PlayerMovementController.cs -> PhysicsBody.cs Position), not the
// sphere centre.
Assert.Equal(new Vector3(72f, 73f, 50.005f), controller.Position);
// CenterCount is 2: ProjectSpawn's initial centering plus the
// ForcePosition's own re-centering above (R2's restored mechanism).
Assert.Equal(2, collision.CenterCount);
}
@ -1296,6 +1336,25 @@ public sealed class HeadlessSessionHostTests
Height: 1.835f,
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
/// <summary>
/// C4 route 2 (2026-08-03): mirrors <see cref="CreateFirstEntryDrive"/>'s
/// construction pattern for the accepted-Position drive controller. No
/// real WorldSession is needed for these fixture tests —
/// LocalPlayerOutboundController.SendImmediatePosition no-ops on a null
/// session.
/// </summary>
private static RuntimeAcceptedPositionDriveController
CreateAcceptedPositionDrive(GameRuntime runtime) => new(
runtime.EntityObjects,
runtime.Clock,
new LoadedSetupCollisionSource(),
new LocalPlayerOutboundController((_, _, _, _, _, _) => { }),
() => runtime.Generation,
() => runtime.PlayerIdentity.ServerGuid,
() => runtime.MovementOwner.Controller,
() => runtime.CharacterOwner.UsePositionFromServer,
() => null);
private sealed class LoadedSetupCollisionSource
: AcDream.Content.IPreparedCollisionSource
{
@ -1312,7 +1371,23 @@ public sealed class HeadlessSessionHostTests
.Loaded(new FlatSetupCollision(
System.Collections.Immutable.ImmutableArray<
FlatCollisionCylinder>.Empty,
[new FlatCollisionSphere(Vector3.Zero, 0.48f)],
// R7 review fix (2026-08-03): the dat-exact human Setup
// 0x02000001 spheres (Ts46SphereListConformanceTests.cs
// :35-39) — foot (0,0,0.475) r=.48, head/torso
// (0,0,1.350) r=.48. The PREVIOUS single dummy sphere at
// (0,0,0) r=.48 (offset == radius) made a settled origin
// rest a FULL radius above the floor; the real foot
// sphere's bottom is origin + 0.475 0.48 = origin
// 0.005, so a settled origin lands ON the floor within
// 5 mm. Retail's BlipPlayer has never lifted the origin
// by a sphere radius — that was a fixture artifact, not
// a retail-fidelity gain (docs/ISSUES.md #285 correction).
[
new FlatCollisionSphere(
new Vector3(0f, 0f, 0.475f), 0.48f),
new FlatCollisionSphere(
new Vector3(0f, 0f, 1.350f), 0.48f),
],
height: 0f,
radius: 0f,
stepUpHeight: 0.4f,

View file

@ -569,7 +569,7 @@ public class PlayerMovementControllerTests
}
[Fact]
public void BlipPosition_ResnapsPoseWithoutStoppingActiveMotion()
public void CommitCanonicalForcePositionFrame_ReconcilesPoseWithoutStoppingActiveMotion()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
@ -579,7 +579,14 @@ public class PlayerMovementControllerTests
Assert.True(velocity.LengthSquared() > 0f);
var corrected = new Vector3(100f, 98f, 50f);
controller.BlipPosition(corrected, 0x0001, corrected);
// C4 route 2: simulates Runtime's canonical SetPosition commit
// (RuntimeSetPositionState.CommitCanonical:4459-4462), which snaps
// the SAME PhysicsBody directly BEFORE the controller reconciles
// its render-lerp/cell state. The deleted BlipPosition used to do
// both steps itself; CommitCanonicalForcePositionFrame only does the
// second.
controller.PhysicsBody.SnapToCell(0x0001, corrected, corrected);
controller.CommitCanonicalForcePositionFrame();
Assert.Equal(corrected, controller.Position);
Assert.Equal(corrected, controller.RenderPosition);
@ -587,13 +594,14 @@ public class PlayerMovementControllerTests
}
[Fact]
public void BlipPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
public void CommitCanonicalForcePositionFrame_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(150f, 193f, 50f);
var wireLocal = new Vector3(150f, 193f, 50f);
controller.BlipPosition(world, 0xA9B30038u, wireLocal);
controller.PhysicsBody.SnapToCell(0xA9B30038u, world, wireLocal);
controller.CommitCanonicalForcePositionFrame();
Assert.Equal(0xA9B40031u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
@ -959,27 +967,35 @@ public class PlayerMovementControllerTests
}
[Fact]
public void BlipPosition_ArmsConstraintButDoesNotTearDownOrZeroVelocity()
public void CommitCanonicalForcePositionFrame_DoesNotRearmConstraintLeashOrTouchVelocity()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var initial = new Vector3(96f, 96f, 50f);
controller.SetPosition(initial, 0x0001);
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocityBeforeBlip = controller.BodyVelocity;
Assert.NotEqual(Vector3.Zero, velocityBeforeBlip); // sanity: actually moving
controller.BlipPosition(
new Vector3(150f, 150f, 50f),
0x0001,
new Vector3(150f, 150f, 50f));
// BlipPlayer (retail 0x00453940) survives motion/velocity/stick — the
// leash is no different: ConstrainTo runs with NO preceding UnConstrain
// and no StopCompletely.
Assert.Equal(velocityBeforeBlip, controller.BodyVelocity);
Vector3 velocityBeforeCommit = controller.BodyVelocity;
Assert.NotEqual(Vector3.Zero, velocityBeforeCommit); // sanity: actually moving
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.True(cm.IsConstrained);
Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
Vector3 leashAnchorBeforeCommit = cm.ConstraintPos.Frame.Origin;
var corrected = new Vector3(150f, 150f, 50f);
// Simulates Runtime's canonical SetPosition commit writing the SAME
// PhysicsBody directly, exactly as CommitCanonicalForcePositionFrame
// expects to find it.
controller.PhysicsBody.SnapToCell(0x0001, corrected, corrected);
controller.CommitCanonicalForcePositionFrame();
// C4 route 2 (2026-08-03): retail's FORCE_POSITION branch of
// SmartBox::HandleReceivedPosition (0x00453FD0) returns at
// 0x0045409D, before every CPhysicsObj::ConstrainTo call
// (0x00454272/0x0045418A/0x004541EC) — the branch BlipPlayer runs
// on is not one of them. Unlike the deleted BlipPosition (which
// re-armed the leash to the corrected position — an unbacked
// deviation), this method must leave the leash anchored exactly
// where it already was. Motion/velocity are untouched either way.
Assert.Equal(velocityBeforeCommit, controller.BodyVelocity);
Assert.Equal(leashAnchorBeforeCommit, cm.ConstraintPos.Frame.Origin);
Assert.NotEqual(corrected, cm.ConstraintPos.Frame.Origin);
}
[Fact]
@ -1133,10 +1149,8 @@ public class PlayerMovementControllerTests
Vector3.One,
0xA9B40021u,
Vector3.One));
Assert.Throws<InvalidOperationException>(() => candidate.BlipPosition(
Vector3.One,
0xA9B40021u,
Vector3.One));
Assert.Throws<InvalidOperationException>(() =>
candidate.CommitCanonicalForcePositionFrame());
Assert.Throws<InvalidOperationException>(() =>
candidate.CaptureMovementResult(mouseLookEvent: false));
Assert.Throws<InvalidOperationException>(() =>

View file

@ -2975,10 +2975,8 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Vector3.One,
Cell,
Vector3.One));
Assert.Throws<InvalidOperationException>(() => controller.BlipPosition(
Vector3.One,
Cell,
Vector3.One));
Assert.Throws<InvalidOperationException>(() =>
controller.CommitCanonicalForcePositionFrame());
Assert.Throws<InvalidOperationException>(() =>
controller.ApplyPhysicsState(PhysicsStateFlags.Frozen));
Assert.Throws<InvalidOperationException>(() => controller.Yaw = 1f);

View file

@ -246,6 +246,73 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.True(runtime.Portal.Snapshot.Completed);
}
/// <summary>
/// R2/R3 review fix (2026-08-03). No accepted-position drive is supplied
/// (mirrors <see cref="ContentLessDirectSink_KeepsPreFlipLegacyRegistration"/>'s
/// "content-less" shape, applied to route 2 specifically), so
/// <c>TryExecuteAcceptedLocalPosition</c> can never run and every
/// ForcePosition resolves <c>NotApplicable</c> at the call site. Proves
/// two things the first implementation pass got wrong: (R2)
/// <see cref="IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition"/>
/// still fires — a host is not left holding a stale collision/streaming
/// window just because the drive is momentarily absent or NotApplicable
/// — and (R3) the pre-existing <c>ProjectPosition</c> fallback still runs
/// for a NotApplicable result, exactly like it did before route 2 existed
/// (the "no legacy fallback to run instead" comment the review found at
/// this exact call site was false).
/// </summary>
[Fact]
public void ForcePositionWithoutAnAcceptedPositionDrive_StillCentersAndFallsBackToProjectPosition()
{
using StartedRuntime started = StartRuntime();
GameRuntime runtime = started.Runtime;
const uint playerGuid = 0x50000005u;
runtime.PlayerIdentity.ServerGuid = playerGuid;
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
new FixtureTransport());
session.GameActionCapture = _ => { };
var projection = new FixtureWorldProjection();
// Deliberately no acceptedPositionDrive argument — mirrors a
// content-less headless host, where the accepted-position drive is
// null and route 2 can never apply.
var controller = new RuntimeLiveEntitySessionController(
runtime,
session,
worldProjection: projection);
LiveEntitySessionSink sink = controller.CreateSink();
WorldSession.EntitySpawn spawn =
Spawn(playerGuid, incarnation: 1);
sink.Spawned(spawn);
Assert.Equal(1, projection.SpawnCount);
Assert.Equal(0, projection.CenterOnForceCount);
sink.PositionUpdated(new WorldSession.EntityPositionUpdate(
playerGuid,
spawn.Position!.Value with
{
PositionX = 30f,
},
Velocity: null,
PlacementId: null,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: 2,
TeleportSequence: 0,
ForcePositionSequence: 1));
Assert.Equal(1, projection.CenterOnForceCount);
Assert.Equal(playerGuid, projection.LastCenteredRecord?.ServerGuid);
// The fallback ran: ProjectPosition observed this exact
// ForcePosition disposition, not just the earlier Apply-shaped spawn
// follow-up.
Assert.Equal(1, projection.PositionCount);
Assert.Equal(
PositionTimestampDisposition.ForcePosition,
projection.LastPositionDisposition);
}
/// <summary>
/// C3c-R1 review F6: the drive controller outlives its session routes,
/// so "session reset precedes a new route" is an asserted latch, not a
@ -674,6 +741,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests
public int PositionCount { get; private set; }
public int TeleportStartCount { get; private set; }
public int PrepareCount { get; private set; }
public int CenterOnForceCount { get; private set; }
public RuntimeEntityRecord? LastCenteredRecord { get; private set; }
public bool LastSpawnWasLocal { get; private set; }
public bool LastPositionWasLocal { get; private set; }
public PositionTimestampDisposition LastPositionDisposition
@ -704,6 +773,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests
LastPositionDisposition = disposition;
}
public void CenterOnAcceptedForcePosition(RuntimeEntityRecord record)
{
CenterOnForceCount++;
LastCenteredRecord = record;
}
public void BeginTeleport() => TeleportStartCount++;
public RuntimeDestinationReadiness PrepareDestination(