diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 95b6c200..770502b6 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -217,6 +217,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) |
| TS-39 | `MoveToManager.StickTo` and `Unstick` are unbound no-op seams (both remote and player bindings leave them null) — retail's sticky arrival hands off to `PositionManager::StickTo(tlid, radius, height)` (BeginNextNode §4b reads the three fields BEFORE CleanUp zeroes them — that ordering IS ported) and every `PerformMovement` head calls `unstick_from_object` → `PositionManager::UnStick`; acdream's sticky moveto (wire bit 0x80) completes-and-stops instead of sticking | `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`StickTo`/`Unstick` seam properties); GameWindow `EnsureRemoteMotionBindings` + `EnterPlayerModeNow` (neither binds them) | PositionManager/StickyManager bodies were NOT extracted in the R4 research (call shapes only, decomp §9f/§9g — do-not-invent); the seams + verbatim call ordering land now, the bodies with R5 | A server /follow-style sticky MoveTo arrives, walks to the target, then STOPS instead of sticking — the entity drifts off the target as it moves until the next re-emit; unstick ordering differences invisible until StickyManager exists | `PositionManager::StickTo` / `UnStick` call shapes (r4-moveto-decomp.md §4b/§3a); R5 StickyManager port retires this row |
+| TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` |
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain; the WRITE side (per-cell contact-plane constraint tracking — a mover pinned between opposing walkable surfaces / doorway jamming) doesn't exist yet | A body that retail would consider fully constrained (and thus refuse to jump, 0x47) never gets blocked here — jumps succeed in geometry retail would reject. Low practical risk today: the constrained-mover scenario (doorway wedge, opposing-wall pin) is rare and mostly a physics-digest topic already tracked separately | `CPhysicsObj::IsFullyConstrained` 0x0050f730; `jump_is_allowed` 0x005282b0 |
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 3484c7f0..015c5c3b 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -603,6 +603,12 @@ public sealed class GameWindow : IDisposable
TransientState = AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active,
+ // R4-V5 door-swing fix: a RemoteMotion exists only for a
+ // WORLD entity — retail's physics_obj->cell is non-null the
+ // moment the object is placed. Without this, the interp's
+ // detached-object guard stripped every dispatched
+ // transition link (see PhysicsBody.InWorld).
+ InWorld = true,
};
Motion = new AcDream.Core.Physics.MotionInterpreter(Body);
}
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index fd6fed65..7a7c492d 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -1247,7 +1247,14 @@ public sealed class MotionInterpreter : IMotionDoneSink
AddToQueue(contextId: 0, MotionCommand.Ready, (uint)jumpSnapshot);
- if (PhysicsObj.CellPosition.ObjCellId == 0)
+ // R4-V5 door-swing fix (2026-07-03): retail's guard is
+ // physics_obj->cell == 0 — DETACHED objects only (raw @305627).
+ // The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded
+ // only by the local player's SnapToCell) read every REMOTE body as
+ // detached, so every dispatch stripped the just-appended transition
+ // link (door swings snapped; remote walk↔run links died) — see
+ // PhysicsBody.InWorld (register row).
+ if (!PhysicsObj.InWorld)
RemoveLinkAnimations?.Invoke();
return WeenieError.None;
@@ -3132,7 +3139,14 @@ public sealed class MotionInterpreter : IMotionDoneSink
result = WeenieError.NotGrounded; // 0x24 (A10) — blocked action-class motion
}
- if (PhysicsObj.CellPosition.ObjCellId == 0)
+ // R4-V5 door-swing fix (2026-07-03): retail's guard is
+ // physics_obj->cell == 0 — DETACHED objects only (raw @305627).
+ // The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded
+ // only by the local player's SnapToCell) read every REMOTE body as
+ // detached, so every dispatch stripped the just-appended transition
+ // link (door swings snapped; remote walk↔run links died) — see
+ // PhysicsBody.InWorld (register row).
+ if (!PhysicsObj.InWorld)
RemoveLinkAnimations?.Invoke();
return result;
@@ -3235,7 +3249,14 @@ public sealed class MotionInterpreter : IMotionDoneSink
}
}
- if (PhysicsObj.CellPosition.ObjCellId == 0)
+ // R4-V5 door-swing fix (2026-07-03): retail's guard is
+ // physics_obj->cell == 0 — DETACHED objects only (raw @305627).
+ // The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded
+ // only by the local player's SnapToCell) read every REMOTE body as
+ // detached, so every dispatch stripped the just-appended transition
+ // link (door swings snapped; remote walk↔run links died) — see
+ // PhysicsBody.InWorld (register row).
+ if (!PhysicsObj.InWorld)
RemoveLinkAnimations?.Invoke();
return result;
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 0608482f..897e90dc 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -115,6 +115,22 @@ public sealed class PhysicsBody
///
public Position CellPosition { get; private set; }
+ ///
+ /// R4-V5 (the door-swing snap fix, 2026-07-03): the retail
+ /// physics_obj->cell != 0 "placed in the world" truth (register
+ /// row). CMotionInterp's dispatch tails strip link animations for
+ /// DETACHED objects only (if (cell == 0) RemoveLinkAnimations,
+ /// raw @305627); the previous proxy — .ObjCellId
+ /// == 0 — was seeded ONLY by the local player's
+ /// (#145 machinery), so every REMOTE body read "detached" forever and
+ /// every dispatched transition link (door swings, walk↔run links) was
+ /// stripped the same tick it was appended. Set by
+ /// (local player placement) and by RemoteMotion construction (remotes
+ /// exist only for world entities). Default false = detached, matching
+ /// retail's pre-enter_world state.
+ ///
+ public bool InWorld { get; set; }
+
///
/// Placement: set the world position AND seed from the
/// wire's (cell, local) — NO streaming center, NO delta. For an OUTDOOR cell the
@@ -137,6 +153,7 @@ public sealed class PhysicsBody
if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u)
LandDefs.AdjustToOutside(ref cell, ref local);
CellPosition = new Position(cell, new CellFrame(local, Orientation));
+ InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
// Mirror a world-position translation into the cell-relative frame. Velocity is
diff --git a/tests/AcDream.Core.Tests/Physics/InWorldLinkGuardTests.cs b/tests/AcDream.Core.Tests/Physics/InWorldLinkGuardTests.cs
new file mode 100644
index 00000000..c6733aed
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/InWorldLinkGuardTests.cs
@@ -0,0 +1,78 @@
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// R4-V5 door-swing fix (2026-07-03, register TS-40): CMotionInterp's
+/// dispatch tails strip link animations for DETACHED objects only (retail
+/// if (physics_obj->cell == 0) RemoveLinkAnimations, raw @305627).
+/// The old proxy (CellPosition.ObjCellId == 0) was seeded only by
+/// the local player's SnapToCell, so every REMOTE body read "detached" and
+/// every dispatched transition link (door open/close swings, remote
+/// walk↔run links) was stripped the same tick it was appended — the pose
+/// snapped straight to the new cycle. These pin the corrected
+/// guard polarity.
+///
+public class InWorldLinkGuardTests
+{
+ [Fact]
+ public void InWorldBody_DispatchKeepsTransitionLinks()
+ {
+ var body = new PhysicsBody
+ {
+ InWorld = true,
+ TransientState = TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable
+ | TransientStateFlags.Active,
+ };
+ var interp = new MotionInterpreter(body);
+ int strips = 0;
+ interp.RemoveLinkAnimations = () => strips++;
+
+ interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
+
+ Assert.Equal(0, strips);
+ }
+
+ [Fact]
+ public void DetachedBody_DispatchStripsLinks_RetailGuard()
+ {
+ var body = new PhysicsBody
+ {
+ // InWorld left false — retail's pre-enter_world detached state.
+ TransientState = TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable
+ | TransientStateFlags.Active,
+ };
+ var interp = new MotionInterpreter(body);
+ int strips = 0;
+ interp.RemoveLinkAnimations = () => strips++;
+
+ interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters());
+
+ Assert.Equal(1, strips);
+ }
+
+ [Fact]
+ public void RemoteShapedBody_StopCompletely_KeepsLinksToo()
+ {
+ // The other two guard sites (StopCompletely / StopInterpretedMotion)
+ // share the same InWorld polarity.
+ var body = new PhysicsBody
+ {
+ InWorld = true,
+ TransientState = TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable
+ | TransientStateFlags.Active,
+ };
+ var interp = new MotionInterpreter(body);
+ int strips = 0;
+ interp.RemoveLinkAnimations = () => strips++;
+
+ interp.StopCompletely();
+
+ Assert.Equal(0, strips);
+ }
+}