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