fix(R4-V5): remote transition links stripped by the detached-object guard - door swings snapped (adds register TS-40)

Root cause (pinned by the worktree investigation + verified against
source): CMotionInterp's dispatch tails strip link animations for
DETACHED objects only (retail `if (physics_obj->cell == 0)
RemoveLinkAnimations`, raw @305627, three ported sites:
DoInterpretedMotion / StopInterpretedMotion / StopCompletely). The R3
port proxied retail's cell pointer with CellPosition.ObjCellId == 0 -
but that #145 field is seeded ONLY by SnapToCell, whose single
production caller is the LOCAL PLAYER's SetPosition. Every REMOTE body
therefore read "detached" forever, and every dispatch stripped the
transition link the motion table had just appended: a used door's
swing link died the same tick (pose snapped closed<->open - the live
symptom), and remote walk<->run link poses have been silently eaten
since the guard landed (masked: locomotion cycles resemble each other;
a door's two poses don't). The dispatcher itself (GetObjectSequence
link path) was verified retail-verbatim end-to-end - not the bug.

Fix: PhysicsBody.InWorld - an explicit "placed in the world" flag
carrying exactly the guard's retail meaning (register row TS-40,
replacing the previously UNREGISTERED ObjCellId proxy). Set by
SnapToCell (retail: enter_world/set_cell assigns the cell) and by
RemoteMotion construction (a RemoteMotion exists only for a world
entity). All three guard sites now test !InWorld. Default false =
retail's pre-enter_world detached state, preserving the guard for
genuinely unplaced bodies (bare-harness interps unchanged).

Tests: InWorldLinkGuardTests pins the polarity at all guard classes
(in-world dispatch keeps links; detached strips; StopCompletely same).
Full suite green: 3,961.

If the door still snaps after this, the open question is DATA (does
the door's table author an On<->Off link at all) - next step per the
investigation: dump the real table's Links/StyleDefaults via a
DoorSetupGfxObjInspectionTests extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 15:10:16 +02:00
parent 006cf6597a
commit 350fb5e3a5
5 changed files with 126 additions and 3 deletions

View file

@ -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 |

View file

@ -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);
}

View file

@ -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;

View file

@ -115,6 +115,22 @@ public sealed class PhysicsBody
/// </summary>
public Position CellPosition { get; private set; }
/// <summary>
/// R4-V5 (the door-swing snap fix, 2026-07-03): the retail
/// <c>physics_obj-&gt;cell != 0</c> "placed in the world" truth (register
/// row). CMotionInterp's dispatch tails strip link animations for
/// DETACHED objects only (<c>if (cell == 0) RemoveLinkAnimations</c>,
/// raw @305627); the previous proxy — <see cref="CellPosition"/>.<c>ObjCellId
/// == 0</c> — was seeded ONLY by the local player's <see cref="SnapToCell"/>
/// (#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 <see cref="SnapToCell"/>
/// (local player placement) and by RemoteMotion construction (remotes
/// exist only for world entities). Default false = detached, matching
/// retail's pre-enter_world state.
/// </summary>
public bool InWorld { get; set; }
/// <summary>
/// Placement: set the world position AND seed <see cref="CellPosition"/> 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

View file

@ -0,0 +1,78 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// R4-V5 door-swing fix (2026-07-03, register TS-40): CMotionInterp's
/// dispatch tails strip link animations for DETACHED objects only (retail
/// <c>if (physics_obj->cell == 0) RemoveLinkAnimations</c>, raw @305627).
/// The old proxy (<c>CellPosition.ObjCellId == 0</c>) 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
/// <see cref="PhysicsBody.InWorld"/> guard polarity.
/// </summary>
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);
}
}