feat(#184): remote-creature de-overlap — placement-snap + shadow-follows-resolved (visual gate passed)
Packed monster remotes interpenetrate in acdream but barely in retail on the same ACE. Retail de-overlaps them CLIENT-side: it sweeps every remote creature every tick against neighbours' LIVE resolved positions (the collision shadow == the resolved m_position, re-registered every moved transition step), with the server position a gentle catch-up target (CPhysicsObj::MoveOrTeleport 0x00516330), not a hard-snap. The collision math was already faithful; the bug was the reconciliation (hard-snap) + the movement model (synth-velocity) + a stale shadow. A first attempt (reverted) enqueued EVERYTHING and left the shadow at the raw server position — it gate-failed with invisible monsters (an unplaced body blipped over a huge distance into the sweep -> garbage pos) and the player stuck on offset shadows. This redo fixes both root causes, mechanism-proven in a Core test first: - NPC UpdatePosition routes through MoveOrTeleport with a PLACEMENT-SNAP: the body is snapped to the server pos when it is not already near it (first UP, no Sequencer to consume the queue, >96 m, or |Body - worldPos| > 4 m); only near DR corrections enqueue. This restores the body's placement authority (no invisible monsters). Airborne keeps the authoritative hard-snap. - Grounded movement drives the body from the interp CATCH-UP (ComputeOffset -> InterpolationManager::adjust_offset, REPLACE dichotomy) instead of synth-velocity (get_state_velocity / SERVERVEL); MovementManager::UseTime runs unconditionally. - SHADOW-FOLLOWS-RESOLVED: after each tick's sweep the collision shadow is re-registered at the resolved body (SyncRemoteShadowToBody), movement-gated (|Body - LastShadowSyncPos| > 1 cm). The per-UP :5669 raw-pos sync is now PLAYERS-ONLY, so an NPC's shadow is only ever written to its resolved body -> neighbours de-overlap against resolved bodies, the spread PERSISTS, and collision == render (no stuck-on-nothing). Landing clears the interp queue. Preserved: airborne path, sticky #171 (gate + StickyManager overwrite of the seeded frame), omega, the #173 bounce, landing, the node_fail_counter watchdog, and Path A (player remotes, untouched -- Slice 2 unifies it). Tests (RemoteDeOverlapMechanismTests): converging pair settles STABLE at 0.86 m (barely overlapping = the retail look) WITH the shadow-sync vs <0.40 m (full overlap) WITHOUT it; a third test drives the REAL InterpolationManager loop and confirms the sweep absorbs the stall-blip (no pop-into-neighbour). 2-lens Opus review (CONCERNS) addressed: movement-gated re-flood for the town-FPS risk; players-only :5669; the blip-absorption test. Register: retires TS-41 (SERVERVEL synth-velocity -> catch-up), narrows TS-44 (NPC UP unified onto the interp queue; gate kept for orientation), adds AP-86 (shadow-follows-resolved impl) + AP-87 (MoveOrTeleport 4 m/no-Sequencer placement snap). Known residual: the de-overlap sweep uses the human sphere for the mover, so large creatures de-overlap at human radii (TS-46; Slice 3 plumbs Setup dims). Visual gate PASSED (user: monsters visible + spacing much better). Core 2620 / App 741 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7f7a78d3ea
commit
37a94e1fa4
4 changed files with 558 additions and 99 deletions
|
|
@ -46,6 +46,51 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #184 — Remote monsters overlap (arms interpenetrating) in a crowd; retail barely overlaps
|
||||||
|
|
||||||
|
**Status:** IN-PROGRESS (redo implemented + reviewed — awaiting the user visual re-gate)
|
||||||
|
**Severity:** MEDIUM
|
||||||
|
**Filed:** 2026-07-07
|
||||||
|
**Component:** physics / remote dead-reckoning
|
||||||
|
|
||||||
|
**Description:** Side-by-side vs retail on the SAME ACE, monsters packed around the player
|
||||||
|
interpenetrate (arms) in acdream where retail keeps them barely overlapping — the "no room to
|
||||||
|
slide out" feel. This is the REMOTE-creature thread — distinct from #182 (the LOCAL-player wedge).
|
||||||
|
|
||||||
|
**Root cause / status:** Retail de-overlaps remotes CLIENT-side: it runs the collision sweep on
|
||||||
|
every remote every tick against neighbours' LIVE resolved positions (the shadow == the resolved
|
||||||
|
`m_position`, re-registered every moved step), with the server pos a GENTLE catch-up target
|
||||||
|
(`MoveOrTeleport` 0x00516330), not a hard-snap. **A first attempt (commit `9c0849dd`) GATE-FAILED
|
||||||
|
(invisible monsters + player stuck on them) and was REVERTED** — it (a) replaced the NPC UP
|
||||||
|
hard-snap with enqueue-everything, losing the body's PLACEMENT authority (an unplaced body blipped
|
||||||
|
over a huge distance into the sweep → garbage pos → invisible), and (b) left the shadow at the raw
|
||||||
|
server pos, so neighbours de-overlapped against overlapping shadows and the player collided with an
|
||||||
|
offset shadow. **REDO (this session):** (1) NPC UP `MoveOrTeleport` with a PLACEMENT-SNAP (snap when
|
||||||
|
the body isn't already near the target — first UP / no-Sequencer / far / >4 m; enqueue only near);
|
||||||
|
(2) grounded movement = interp catch-up feeding the kept sweep; (3) **shadow-follows-resolved** —
|
||||||
|
the shadow is re-registered at the resolved body every moving tick (`SyncRemoteShadowToBody`,
|
||||||
|
movement-gated; `:5669` raw sync now players-only). Retires TS-41, narrows TS-44, adds AP-86/AP-87.
|
||||||
|
Mechanism proven in Core (`RemoteDeOverlapMechanismTests`: with-sync 0.86 m stable / without <0.40 m;
|
||||||
|
the real-interp loop absorbs the stall-blip). 2-lens Opus review CONCERNS → all addressed
|
||||||
|
(movement-gate for the town-FPS risk; players-only `:5669`; the blip-absorption test). Core 2620 /
|
||||||
|
App 741 green.
|
||||||
|
|
||||||
|
**Known residual:** the de-overlap sweep uses the fixed human sphere (R 0.48) for the mover, so
|
||||||
|
large packed creatures de-overlap at human radii (**TS-46**; Setup-derived dims = Slice 3).
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (NPC `OnLivePositionUpdated` ~:5960 MoveOrTeleport
|
||||||
|
+ shadow sync; Path B tick catch-up/sweep/shadow-sync; `SyncRemoteShadowToBody`).
|
||||||
|
|
||||||
|
**Research:** design `docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md`; handoff
|
||||||
|
`docs/research/2026-07-07-remote-creature-deoverlap-handoff.md`; digest `claude-memory/project_physics_collision_digest.md` (2026-07-07 top).
|
||||||
|
|
||||||
|
**Acceptance:** side-by-side vs retail — packed monsters spread to retail spacing (arms no longer
|
||||||
|
interpenetrating), monsters VISIBLE (not stuck-on-nothing), no ~3 Hz jitter in a pack, no town-FPS
|
||||||
|
drop; remotes don't rubber-band/desync; sticky #171 facing unbroken; walk/run/jump/land unchanged.
|
||||||
|
Then Slice 2 (unify Path A + `RemotePhysicsUpdater` extraction) and Slice 3 (Setup-derived sphere).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #183 — Floating distant scenery: trees from another biome hover in the distance
|
## #183 — Floating distant scenery: trees from another biome hover in the distance
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 74 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45)
|
## 3. Documented approximation (AP) — 76 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45; AP-86/AP-87 added 2026-07-07 — the remote-creature de-overlap #184: shadow-follows-resolved via a movement-gated re-flood, and the NPC MoveOrTeleport 4 m/no-Sequencer placement snap)
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -188,8 +188,10 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
||||||
| AP-84 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port (2026-07-07, retires TS-45) reads `collide_with_point`'s PerfectClip time-of-impact math from ACE `Sphere.CollideWithPoint` + `FindTimeOfCollision` (ported as `FindSphereTimeOfCollision`) because the BN x87 mush is unreadable there. NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — `SetCollisionNormal` + Collided — is decomp-verified). Sibling of AP-83 (the cylinder family's identical dead branch) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint` PerfectClip branch + `FindSphereTimeOfCollision`; pseudocode doc `docs/research/2026-07-07-csphere-collision-family-pseudocode.md` §6/§8.2) | The load-bearing path (non-PerfectClip Collided; the family's step-up/slide/land) is decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the ACE-derived TOI may diverge from retail — clip-through / wrong deflection on sphere targets; re-decompile 0x00537230 in Ghidra before shipping missiles | `CSphere::collide_with_point` 0x00537230 (pc:321327); ACE Sphere.cs `CollideWithPoint` |
|
| AP-84 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port (2026-07-07, retires TS-45) reads `collide_with_point`'s PerfectClip time-of-impact math from ACE `Sphere.CollideWithPoint` + `FindTimeOfCollision` (ported as `FindSphereTimeOfCollision`) because the BN x87 mush is unreadable there. NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — `SetCollisionNormal` + Collided — is decomp-verified). Sibling of AP-83 (the cylinder family's identical dead branch) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint` PerfectClip branch + `FindSphereTimeOfCollision`; pseudocode doc `docs/research/2026-07-07-csphere-collision-family-pseudocode.md` §6/§8.2) | The load-bearing path (non-PerfectClip Collided; the family's step-up/slide/land) is decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the ACE-derived TOI may diverge from retail — clip-through / wrong deflection on sphere targets; re-decompile 0x00537230 in Ghidra before shipping missiles | `CSphere::collide_with_point` 0x00537230 (pc:321327); ACE Sphere.cs `CollideWithPoint` |
|
||||||
|
| AP-86 | **Remote SHADOW-follows-resolved via a movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody` → `ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal`→`remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync (`:5669`) is now PLAYERS-ONLY (a player remote tracks the server pos closely, so raw-pos is fine; an NPC's body is de-overlapped behind it, so raw-pos there would snap the shadow into overlap for a frame each UP). Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip) | `src/AcDream.App/Rendering/GameWindow.cs` (`SyncRemoteShadowToBody`, the Path B tick guard, the players-only `:5669`, the NPC-branch tail sync); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) |
|
||||||
|
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
||||||
|
|
||||||
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge)
|
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -228,9 +230,9 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| 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. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
|
| 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. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
|
||||||
| 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-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 |
|
| 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 |
|
||||||
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
|
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
|
||||||
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
|
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
|
||||||
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
|
| TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 |
|
||||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
|
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
|
||||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,15 @@ public sealed class GameWindow : IDisposable
|
||||||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||||
public System.Numerics.Vector3 LastServerPos;
|
public System.Numerics.Vector3 LastServerPos;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// #184: the body position at which this remote's collision SHADOW was
|
||||||
|
/// last (re-)registered. The per-tick shadow-follows-resolved sync
|
||||||
|
/// (<c>SyncRemoteShadowToBody</c>) skips re-flooding when the body has
|
||||||
|
/// not moved > ε since this — a resting/idle-town crowd doesn't churn
|
||||||
|
/// the registry, while a moving/de-overlapping crowd re-registers every
|
||||||
|
/// tick. Sentinel <c>Zero</c> forces the first sync.
|
||||||
|
/// </summary>
|
||||||
|
public System.Numerics.Vector3 LastShadowSyncPos;
|
||||||
|
/// <summary>
|
||||||
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
||||||
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
||||||
/// it for a server-controlled creature, derive it from consecutive
|
/// it for a server-controlled creature, derive it from consecutive
|
||||||
|
|
@ -4608,6 +4617,31 @@ public sealed class GameWindow : IDisposable
|
||||||
+ delta.GetHeading());
|
+ delta.GetHeading());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
|
||||||
|
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
|
||||||
|
/// de-overlap / collide against where the monster actually IS (== where it
|
||||||
|
/// renders), not the raw overlapping server position. Retail re-registers a
|
||||||
|
/// moved object's shadow every accepted transition step (SetPositionInternal
|
||||||
|
/// → remove/add_shadows_to_cells, Ghidra 0x00515330); this is the acdream
|
||||||
|
/// equivalent driven from the remote DR loop. worldOffset is recomputed from
|
||||||
|
/// <paramref name="rm"/>.CellId + the streaming centre, exactly as the UP
|
||||||
|
/// handler derives <c>origin</c> (:5618) — and is DEAD anyway when seedCellId
|
||||||
|
/// is supplied (it only feeds the short-circuited DeriveOutdoorSeed). Updates
|
||||||
|
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
|
||||||
|
/// </summary>
|
||||||
|
private void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm)
|
||||||
|
{
|
||||||
|
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
|
||||||
|
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
|
||||||
|
float shOffX = (shLbX - _liveCenterX) * 192f;
|
||||||
|
float shOffY = (shLbY - _liveCenterY) * 192f;
|
||||||
|
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||||
|
entityId, rm.Body.Position, rm.Body.Orientation,
|
||||||
|
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
|
||||||
|
rm.LastShadowSyncPos = rm.Body.Position;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
||||||
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
|
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
|
||||||
|
|
@ -5662,8 +5696,15 @@ public sealed class GameWindow : IDisposable
|
||||||
// (its body is the simulator, not a target). Retail does the
|
// (its body is the simulator, not a target). Retail does the
|
||||||
// equivalent via SetPosition → change_cell → AddShadowObject
|
// equivalent via SetPosition → change_cell → AddShadowObject
|
||||||
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
|
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
|
||||||
if (update.Guid != _playerServerGuid)
|
if (update.Guid != _playerServerGuid && IsPlayerGuid(update.Guid))
|
||||||
{
|
{
|
||||||
|
// Remote PLAYERS only (#184): a player remote (Path A, no sweep) tracks
|
||||||
|
// the server position closely, so syncing its shadow to the raw pos is
|
||||||
|
// fine. NPC shadows are synced to the RESOLVED body instead — by the
|
||||||
|
// NPC-branch tail below and the per-tick DR loop (SyncRemoteShadowToBody)
|
||||||
|
// — because an NPC's body is de-overlapped BEHIND its raw server pos;
|
||||||
|
// writing the raw pos here would snap the shadow into overlap for a frame
|
||||||
|
// each UP and fight the de-overlap (the #184 review's :5669 finding).
|
||||||
// BR-7: the wire position's full cell id seeds the re-flood
|
// BR-7: the wire position's full cell id seeds the re-flood
|
||||||
// (retail SetPosition → calc_cross_cells from m_position).
|
// (retail SetPosition → calc_cross_cells from m_position).
|
||||||
_physicsEngine.ShadowObjects.UpdatePosition(
|
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||||
|
|
@ -5923,7 +5964,69 @@ public sealed class GameWindow : IDisposable
|
||||||
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
|
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
|
||||||
}
|
}
|
||||||
if (!snapSuppressedByStick)
|
if (!snapSuppressedByStick)
|
||||||
rmState.Body.Position = worldPos;
|
{
|
||||||
|
// #184 (2026-07-07): retail CPhysicsObj::MoveOrTeleport (0x00516330)
|
||||||
|
// for grounded NPC remotes. The body is PLACED (hard-snapped) whenever
|
||||||
|
// it is not already tracking NEAR the server position — the first UP,
|
||||||
|
// a large correction / teleport, an out-of-view creature (>96 m from
|
||||||
|
// the local player), or an entity the DR loop won't tick — otherwise the
|
||||||
|
// server point is a GENTLE dead-reckoning TARGET the per-tick interp
|
||||||
|
// catch-up walks to, and the KEPT sweep de-overlaps that movement.
|
||||||
|
//
|
||||||
|
// The placement-snap is LOAD-BEARING: the earlier attempt (reverted)
|
||||||
|
// enqueued EVERYTHING, so an unplaced body (origin, first UP) blipped
|
||||||
|
// over a huge distance into the sweep -> a resolve started in a cell that
|
||||||
|
// did not contain the body -> garbage resolved pos -> INVISIBLE monster
|
||||||
|
// while its shadow (synced to server truth) stayed put -> player stuck on
|
||||||
|
// nothing. Airborne keeps the authoritative hard-snap (arc integrates
|
||||||
|
// locally, K-fix15). Physics digest 2026-07-07 banner.
|
||||||
|
if (rmState.Airborne)
|
||||||
|
{
|
||||||
|
rmState.Body.Position = worldPos;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap
|
||||||
|
const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap
|
||||||
|
var localPlayerPosNpc = _playerController?.Position
|
||||||
|
?? System.Numerics.Vector3.Zero;
|
||||||
|
float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc);
|
||||||
|
float bodyToTargetNpc = System.Numerics.Vector3.Distance(
|
||||||
|
rmState.Body.Position, worldPos);
|
||||||
|
// The 4 m bodyToTargetNpc guard is the LOAD-BEARING placement backstop:
|
||||||
|
// it snaps any body not already near the target (an unplaced first-UP
|
||||||
|
// body sits at the origin / spawn pos, far from worldPos). firstUpNpc is
|
||||||
|
// a belt-and-suspenders hint only — it is NOT a reliable "never placed"
|
||||||
|
// signal because a UM that enters a locomotion cycle can stamp
|
||||||
|
// LastServerPosTime before the first UP (~:5340). Don't tune the 4 m
|
||||||
|
// threshold down without re-checking the unplaced-body case.
|
||||||
|
bool firstUpNpc = rmState.LastServerPosTime <= 0.0;
|
||||||
|
// Enqueue only if the per-tick DR loop will CONSUME the queue (a
|
||||||
|
// non-null Sequencer, the TickAnimations gate); else nothing walks
|
||||||
|
// the body to the waypoint and it would freeze at its last pos.
|
||||||
|
bool willBeDrTickedNpc =
|
||||||
|
_animatedEntities.TryGetValue(entity.Id, out var aeDrNpc)
|
||||||
|
&& aeDrNpc.Sequencer is not null;
|
||||||
|
|
||||||
|
if (firstUpNpc || !willBeDrTickedNpc
|
||||||
|
|| distNpc > MaxPhysicsDistanceNpc
|
||||||
|
|| bodyToTargetNpc > BodySnapThresholdNpc)
|
||||||
|
{
|
||||||
|
// Placement / far / large-correction: SNAP + clear queue.
|
||||||
|
rmState.Interp.Clear();
|
||||||
|
rmState.Body.Position = worldPos;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Near DR correction: enqueue the waypoint for the per-tick
|
||||||
|
// catch-up (Path B consumes it via ComputeOffset).
|
||||||
|
float headingNpc = ExtractYawFromQuaternion(rot);
|
||||||
|
rmState.Interp.Enqueue(
|
||||||
|
worldPos, headingNpc, isMovingTo: false,
|
||||||
|
currentBodyPosition: rmState.Body.Position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||||||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||||||
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
|
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
|
||||||
|
|
@ -6005,8 +6108,14 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity
|
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity
|
||||||
&& !snapSuppressedByStick)
|
&& !snapSuppressedByStick && rmState.Airborne)
|
||||||
{
|
{
|
||||||
|
// AIRBORNE-only (#184): a grounded NPC translates by the interp catch-up,
|
||||||
|
// and the per-tick grounded loop zeroes Body.Velocity before integrating —
|
||||||
|
// so this write is DEAD for grounded remotes and only muddies the
|
||||||
|
// "ServerVelocity is diagnostic-only" contract. Gating it to airborne keeps
|
||||||
|
// the arc's velocity re-sync and prevents synth velocity from leaking into a
|
||||||
|
// grounded integrate.
|
||||||
rmState.Body.Velocity = rmState.ServerVelocity;
|
rmState.Body.Velocity = rmState.ServerVelocity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6037,6 +6146,15 @@ public sealed class GameWindow : IDisposable
|
||||||
rmState.ServerVelocity);
|
rmState.ServerVelocity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #184: sync the NPC shadow to the resolved/placed body (NOT the raw
|
||||||
|
// server pos — the :5669 sync is players-only now) so collision ==
|
||||||
|
// render and the de-overlap isn't snapped away for a frame each UP.
|
||||||
|
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
|
||||||
|
// the per-tick loop skips). The per-tick loop keeps it current between
|
||||||
|
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
||||||
|
if (rmState.CellId != 0)
|
||||||
|
SyncRemoteShadowToBody(entity.Id, rmState);
|
||||||
|
|
||||||
entity.SetPosition(rmState.Body.Position);
|
entity.SetPosition(rmState.Body.Position);
|
||||||
entity.Rotation = rmState.Body.Orientation;
|
entity.Rotation = rmState.Body.Orientation;
|
||||||
}
|
}
|
||||||
|
|
@ -10356,43 +10474,30 @@ public sealed class GameWindow : IDisposable
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||||
// #170 residual fix (2026-07-04): an ARMED moveto always
|
|
||||||
// takes the MOVETO branch. The old arbitration routed the
|
// #184 (2026-07-07): a grounded remote carries NO translation
|
||||||
// tick to SERVERVEL whenever UPs flowed (position-delta
|
// velocity. Its per-tick movement is the interp CATCH-UP toward
|
||||||
// synthesis sets HasServerVelocity=true for any moving
|
// the MoveOrTeleport-queued server waypoint (computed at the
|
||||||
// NPC), which SKIPPED MoveToManager.UseTime for exactly
|
// sticky-compose site below), which the KEPT ResolveWithTransition
|
||||||
// the duration of the server-side chase — the armed
|
// sweep de-overlaps against neighbours — and the resolved position
|
||||||
// manager only ever ticked in UP-silent gaps (creature
|
// is written back into the SHADOW (below) so the de-overlap
|
||||||
// stopped server-side), turned toward a stale heading,
|
// persists and neighbours collide against the resolved body, not
|
||||||
// and was interrupted by the next UM before reaching
|
// the raw server pos. This REPLACES the old synth-velocity model
|
||||||
// BeginMoveForward. Live funnel (launch-drainq.log,
|
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
|
||||||
// corrected per-guid attribution): 16 arms → 11 turns
|
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
|
||||||
// dispatched → 1 run install; [npc-tick] shows
|
// leg — a remote translates by adjust_offset and the UP is a gentle
|
||||||
// branch=SERVERVEL (skips UseTime) mtState=MoveToObject
|
// target. Matches Path A's grounded model (:10113); clearing
|
||||||
// for chasing scamps. The legs stayed in Ready while the
|
// velocity mirrors Path A (:10125).
|
||||||
// body glided on synthesized velocity — the #170 slide.
|
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||||
// Retail runs MovementManager::UseTime UNCONDITIONALLY
|
|
||||||
// per tick (CPhysicsObj::UpdateObjectInternal 0x005156b0,
|
// Stale server-velocity → stop the locomotion CYCLE (the legs).
|
||||||
// call at 0x00515998) and has NO wire-velocity leg-driver;
|
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
|
||||||
// between UPs a moveto-driven body translates from the
|
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
|
||||||
// motion state (get_state_velocity) with UP hard-snaps
|
// so a scripted-path NPC that stops server-side drops out of its
|
||||||
// correcting drift. The SERVERVEL leg remains ONLY as the
|
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
|
||||||
// legacy fallback for entities WITHOUT an armed moveto
|
// anim from ServerVelocity, independent of Body.Velocity.
|
||||||
// (scripted-path NPCs / missiles carrying wire velocity).
|
|
||||||
// Full-stack conformance: RemoteChaseEndToEndHarnessTests
|
|
||||||
// (turn→run→drain sustained when the manager is ticked).
|
|
||||||
bool moveToArmed = rm.MoveTo is
|
bool moveToArmed = rm.MoveTo is
|
||||||
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
|
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
|
||||||
// R5-V3 (#171): a STUCK entity is also client-side-
|
|
||||||
// driven — after the sticky arrival the moveto is
|
|
||||||
// cleaned (Invalid) but StickyManager::adjust_offset
|
|
||||||
// owns the between-snap translation exactly like an
|
|
||||||
// armed moveto. Routing it to SERVERVEL would glide
|
|
||||||
// the body on synthesized wire velocity AGAINST the
|
|
||||||
// sticky steer (the same starvation class as the #170
|
|
||||||
// SERVERVEL fix — register TS-41). Retail has no
|
|
||||||
// SERVERVEL leg at all; it stays the fallback for
|
|
||||||
// entities with NO client-side movement driver.
|
|
||||||
bool stickyArmed =
|
bool stickyArmed =
|
||||||
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
||||||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
|
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
|
||||||
|
|
@ -10403,62 +10508,23 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||||
rm.HasServerVelocity = false;
|
rm.HasServerVelocity = false;
|
||||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
|
||||||
ApplyServerControlledVelocityCycle(
|
ApplyServerControlledVelocityCycle(
|
||||||
serverGuid,
|
serverGuid,
|
||||||
ae,
|
ae,
|
||||||
rm,
|
rm,
|
||||||
System.Numerics.Vector3.Zero);
|
System.Numerics.Vector3.Zero);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
rm.Body.Velocity = rm.ServerVelocity;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// R4-V4: the retail MoveToManager drives
|
|
||||||
// server-directed movement per tick — UseTime runs
|
|
||||||
// HandleMoveToPosition/HandleTurnToHeading (steering
|
|
||||||
// + arrival + fail-distance), dispatching its OWN
|
|
||||||
// per-node locomotion (turn / RunForward) through the
|
|
||||||
// sink. That per-node dispatch is the only motion the
|
|
||||||
// per-tick update should issue for a remote.
|
|
||||||
TickRemoteMoveTo(rm);
|
|
||||||
|
|
||||||
// #170 ROOT FIX (2026-07-04): the per-frame
|
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
|
||||||
// rm.Motion.apply_current_movement(...) call that used
|
// MovementManager::UseTime per tick, UpdateObjectInternal call
|
||||||
// to be here is DELETED. For a remote (which has a
|
// @0x00515998) — UseTime runs HandleMoveToPosition /
|
||||||
// DefaultSink) it re-ran ApplyInterpretedMovement EVERY
|
// HandleTurnToHeading (steering + arrival + fail-distance),
|
||||||
// FRAME, re-dispatching the whole interpreted state —
|
// dispatching its per-node locomotion (turn / RunForward) through
|
||||||
// stance (0x8000003C), forward=attack, sidestep/turn
|
// the sink (the LEGS). Position comes from the catch-up; legs from
|
||||||
// stops — and each successful DoInterpretedMotion
|
// this per-node dispatch + the funnel. The #170-deleted per-frame
|
||||||
// appends a CMotionInterp.pending_motions node. Those
|
// apply_current_movement is NOT reintroduced.
|
||||||
// nodes barely drain for a remote, so pending_motions
|
TickRemoteMoveTo(rm);
|
||||||
// EXPLODED to ~1.3M entries (live capture: add=1.37M vs
|
|
||||||
// done=5.7K; ~671K of them the unchanged stance).
|
|
||||||
// MotionsPending() then stayed permanently true, so
|
|
||||||
// MoveToManager.BeginTurnToHeading (0x00529b90,
|
|
||||||
// `if (motions_pending) return`) could never start the
|
|
||||||
// chase turn → the chase never reached BeginMoveForward
|
|
||||||
// → RunForward, so the creature SLID in an idle+attack
|
|
||||||
// pose instead of running. Retail dispatches per MOTION
|
|
||||||
// EVENT (per UM), never per frame — a live cdb drain
|
|
||||||
// trace showed retail add_to_queue == MotionDone (queue
|
|
||||||
// stays shallow). The motion is already dispatched per
|
|
||||||
// UM by the funnel (MoveToInterpretedState) and by the
|
|
||||||
// MoveToManager per node above; the only thing the
|
|
||||||
// deleted call still provided was body velocity, which
|
|
||||||
// the get_state_velocity refresh below performs
|
|
||||||
// directly. get_state_velocity (0x00527d50, verbatim)
|
|
||||||
// returns WalkForward→3.12×spd, RunForward→4.0×spd, 0
|
|
||||||
// otherwise; grounded-only (this branch already asserted
|
|
||||||
// Contact+OnWalkable), airborne velocity owns the jump.
|
|
||||||
if (rm.Body.OnWalkable)
|
|
||||||
rm.Body.set_local_velocity(
|
|
||||||
rm.Motion.get_state_velocity(),
|
|
||||||
rm.Body.LastMoveWasAutonomous);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -10510,20 +10576,72 @@ public sealed class GameWindow : IDisposable
|
||||||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||||
// double-integrating.
|
// double-integrating.
|
||||||
var preIntegratePos = rm.Body.Position;
|
var preIntegratePos = rm.Body.Position;
|
||||||
// R5-V3 (#171): the sticky steer — retail
|
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
|
||||||
// UpdatePositionInternal's PositionManager::adjust_offset
|
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
|
||||||
// slot (0x00512c30, call @0x00512d0e): the delta composes
|
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
|
||||||
// into this tick's motion BEFORE UpdatePhysicsInternal +
|
// sweep so collision resolves whichever movement won (preIntegratePos
|
||||||
// the transition sweep below, so collision resolves the
|
// captured first — the sweep covers it).
|
||||||
// sticky movement like any other motion (preIntegratePos
|
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
|
||||||
// is captured first — the sweep covers the steer). No-op
|
// the movement source is the adjust_offset walk toward the
|
||||||
// when nothing is stuck (untouched delta frame).
|
// MoveOrTeleport-queued server waypoint, exactly like Path A
|
||||||
|
// (:10173). StickyManager::adjust_offset then OVERWRITES the
|
||||||
|
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
|
||||||
|
// dichotomy), so a stuck monster still steers via #171.
|
||||||
|
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
|
||||||
|
// from velocity + gravity, unchanged).
|
||||||
|
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
|
||||||
|
// adds no translation on top of the catch-up — no double-move.
|
||||||
if (rm.Host is { } npcHost)
|
if (rm.Host is { } npcHost)
|
||||||
{
|
{
|
||||||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
|
||||||
|
if (!rm.Airborne)
|
||||||
|
{
|
||||||
|
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||||
|
?? System.Numerics.Vector3.Zero;
|
||||||
|
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||||
|
System.Numerics.Vector3? terrainNormalNpc =
|
||||||
|
_physicsEngine.SampleTerrainNormal(
|
||||||
|
rm.Body.Position.X, rm.Body.Position.Y);
|
||||||
|
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
||||||
|
dt: (double)dt,
|
||||||
|
currentBodyPosition: rm.Body.Position,
|
||||||
|
seqVel: seqVelNpc,
|
||||||
|
ori: rm.Body.Orientation,
|
||||||
|
interp: rm.Interp,
|
||||||
|
maxSpeed: maxSpeedNpc,
|
||||||
|
terrainNormal: terrainNormalNpc);
|
||||||
|
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||||
|
{
|
||||||
|
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||||
|
rm.Body.Orientation, offsetNpc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||||
|
}
|
||||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||||
}
|
}
|
||||||
|
else if (!rm.Airborne)
|
||||||
|
{
|
||||||
|
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||||
|
// directly, matching Path A's fallback (:10202).
|
||||||
|
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||||
|
?? System.Numerics.Vector3.Zero;
|
||||||
|
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||||
|
System.Numerics.Vector3? terrainNormalNpc =
|
||||||
|
_physicsEngine.SampleTerrainNormal(
|
||||||
|
rm.Body.Position.X, rm.Body.Position.Y);
|
||||||
|
rm.Body.Position += rm.Position.ComputeOffset(
|
||||||
|
dt: (double)dt,
|
||||||
|
currentBodyPosition: rm.Body.Position,
|
||||||
|
seqVel: seqVelNpc,
|
||||||
|
ori: rm.Body.Orientation,
|
||||||
|
interp: rm.Interp,
|
||||||
|
maxSpeed: maxSpeedNpc,
|
||||||
|
terrainNormal: terrainNormalNpc);
|
||||||
|
}
|
||||||
rm.Body.calc_acceleration();
|
rm.Body.calc_acceleration();
|
||||||
rm.Body.UpdatePhysicsInternal(dt);
|
rm.Body.UpdatePhysicsInternal(dt);
|
||||||
var postIntegratePos = rm.Body.Position;
|
var postIntegratePos = rm.Body.Position;
|
||||||
|
|
@ -10586,6 +10704,36 @@ public sealed class GameWindow : IDisposable
|
||||||
if (resolveResult.CellId != 0)
|
if (resolveResult.CellId != 0)
|
||||||
rm.CellId = resolveResult.CellId;
|
rm.CellId = resolveResult.CellId;
|
||||||
|
|
||||||
|
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
|
||||||
|
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
|
||||||
|
// re-registers a moved object's shadow every transition step
|
||||||
|
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
|
||||||
|
// 0x00515330) so its m_position — the RESOLVED position — is what
|
||||||
|
// OTHER creatures collide against. acdream's shadow otherwise only
|
||||||
|
// syncs to the RAW server pos on UpdatePosition, so neighbours would
|
||||||
|
// de-overlap against each other's OVERLAPPING shadows and any spread
|
||||||
|
// would be discarded on the next UP (never accumulating), AND the
|
||||||
|
// player would collide with a shadow offset from where the monster
|
||||||
|
// renders (the reverted attempt's "stuck on an invisible monster").
|
||||||
|
// Syncing the shadow to the resolved body every tick makes the
|
||||||
|
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
|
||||||
|
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
||||||
|
// body moved > ~1 cm since the last shadow registration. This is
|
||||||
|
// SAFE now that the per-UP :5669 sync is players-only — the NPC
|
||||||
|
// shadow's only writers are this loop + the UP-branch tail, both to
|
||||||
|
// the resolved body, so a net-stationary (de-overlapped, sweep-
|
||||||
|
// blocked) creature keeps its correct shadow and never re-floods,
|
||||||
|
// while a moving/de-overlapping crowd (which moves every tick) still
|
||||||
|
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
|
||||||
|
// to actually-moving remotes — the perf risk the review flagged for
|
||||||
|
// a packed town. (In-place shadow-move + cell-relink-on-change is a
|
||||||
|
// further optimization if profiling still shows churn.)
|
||||||
|
if (System.Numerics.Vector3.DistanceSquared(
|
||||||
|
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
||||||
|
{
|
||||||
|
SyncRemoteShadowToBody(kv.Key, rm);
|
||||||
|
}
|
||||||
|
|
||||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||||
// remote objects included; a VectorUpdate-launched jump arc
|
// remote objects included; a VectorUpdate-launched jump arc
|
||||||
|
|
@ -10654,6 +10802,11 @@ public sealed class GameWindow : IDisposable
|
||||||
&& rm.Body.Velocity.Z <= 0f)
|
&& rm.Body.Velocity.Z <= 0f)
|
||||||
{
|
{
|
||||||
rm.Airborne = false;
|
rm.Airborne = false;
|
||||||
|
// #184 (2026-07-07): clear the interp queue on landing (mirrors
|
||||||
|
// the player-remote landing). Airborne UPs hard-snap and never
|
||||||
|
// Enqueue, so any pre-jump waypoints are stale; without this the
|
||||||
|
// first grounded catch-up after touchdown chases them backward.
|
||||||
|
rm.Interp.Clear();
|
||||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
||||||
rm.Body.Velocity = new System.Numerics.Vector3(
|
rm.Body.Velocity = new System.Numerics.Vector3(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mechanism validation for the remote-creature de-overlap redo (#184).
|
||||||
|
///
|
||||||
|
/// The reported symptom: packed monsters interpenetrate in acdream but barely in retail on
|
||||||
|
/// the SAME ACE. Retail de-overlaps them CLIENT-side by running the collision sweep on every
|
||||||
|
/// remote creature every tick against its neighbours' LIVE positions (the shadow == the
|
||||||
|
/// resolved m_position), with the server position a gentle catch-up target.
|
||||||
|
///
|
||||||
|
/// The first attempt (reverted commit 9c0849dd) synced the broadphase shadow to the RAW
|
||||||
|
/// server position and never to the resolved body — so each creature de-overlapped against a
|
||||||
|
/// STALE overlapping shadow and any separation was discarded on the next update. These tests
|
||||||
|
/// prove, in Core, the two load-bearing facts BEFORE wiring GameWindow (so we don't spend
|
||||||
|
/// another visual gate on an unproven mechanism):
|
||||||
|
///
|
||||||
|
/// 1. catch-up (approach the target) + ResolveWithTransition sweep + shadow-follows-resolved
|
||||||
|
/// => two converging creatures SETTLE at contact-distance (sum of radii), not overlapping
|
||||||
|
/// — the PROACTIVE de-overlap (each is stopped short of its overlapping target by its
|
||||||
|
/// neighbour) and PERSISTENCE (the stopped position holds because the shadow tracks it).
|
||||||
|
///
|
||||||
|
/// 2. the SAME loop WITHOUT the shadow-follows-resolved sync (shadow left at each creature's
|
||||||
|
/// START / server-truth position) => the creatures OVERLAP — proving the sync is
|
||||||
|
/// load-bearing, not a residual (all three Slice-1 reviewers flagged this; it was wrongly
|
||||||
|
/// deferred).
|
||||||
|
///
|
||||||
|
/// Grounded creature-vs-creature, flat terrain, purely-horizontal convergence so the vertical
|
||||||
|
/// axis is stable and the assertion is about XY separation. Sphere dims = the human Setup
|
||||||
|
/// (R 0.48, capsule top 1.835 — TS-46). Mirrors the Path B sweep call in GameWindow
|
||||||
|
/// (isOnGround, EdgeSlide, self-skip via movingEntityId).
|
||||||
|
/// </summary>
|
||||||
|
public class RemoteDeOverlapMechanismTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public RemoteDeOverlapMechanismTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const uint Lb = 0xA9B40000u;
|
||||||
|
private const uint Cell = Lb | 0x0001u;
|
||||||
|
private const float R = 0.48f, H = 1.835f, StepUp = 0.60f, StepDown = 0.60f;
|
||||||
|
private const float ContactDist = 2f * R; // 0.96 m centre-to-centre when just touching
|
||||||
|
private const float GroundZ = R; // foot sphere resting on the flat (Z=0) terrain
|
||||||
|
private const float StepPerTick = 0.03f; // catch-up step magnitude toward the target
|
||||||
|
// The pair freezes at the first-contact position, so the residual overlap at rest is ~one
|
||||||
|
// catch-up step (validate_transition restores curr_pos on the step that would deepen the
|
||||||
|
// overlap). Accept up to ~2 steps of slack; a finer step settles nearer ContactDist.
|
||||||
|
private const float SettleSlack = 2f * StepPerTick + 0.01f;
|
||||||
|
|
||||||
|
private static PhysicsEngine BuildEngine()
|
||||||
|
{
|
||||||
|
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||||
|
engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]),
|
||||||
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterCreatureAt(PhysicsEngine e, uint id, Vector3 c)
|
||||||
|
=> e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R,
|
||||||
|
0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u,
|
||||||
|
EntityCollisionFlags.IsCreature, isStatic: false);
|
||||||
|
|
||||||
|
private static PhysicsBody GroundedBody(Vector3 pos) => new PhysicsBody
|
||||||
|
{
|
||||||
|
Position = pos,
|
||||||
|
Orientation = Quaternion.Identity,
|
||||||
|
State = PhysicsStateFlags.ReportCollisions,
|
||||||
|
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable
|
||||||
|
| TransientStateFlags.Active,
|
||||||
|
Velocity = Vector3.Zero,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>One catch-up-step + sweep for one creature; returns the resolved position.</summary>
|
||||||
|
private Vector3 StepToward(PhysicsEngine engine, uint id, PhysicsBody body, ref uint cell,
|
||||||
|
Vector3 target)
|
||||||
|
{
|
||||||
|
Vector3 pre = body.Position;
|
||||||
|
Vector3 flatTarget = new Vector3(target.X, target.Y, pre.Z);
|
||||||
|
Vector3 delta = flatTarget - pre;
|
||||||
|
float dist = delta.Length();
|
||||||
|
Vector3 post = dist <= StepPerTick ? flatTarget : pre + delta / dist * StepPerTick;
|
||||||
|
|
||||||
|
var r = engine.ResolveWithTransition(pre, post, cell, R, H, StepUp, StepDown,
|
||||||
|
isOnGround: true, body: body,
|
||||||
|
moverFlags: ObjectInfoState.EdgeSlide,
|
||||||
|
movingEntityId: id);
|
||||||
|
|
||||||
|
body.Position = r.Position;
|
||||||
|
if (r.CellId != 0) cell = r.CellId;
|
||||||
|
return body.Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConvergingCreatures_WithShadowFollowingResolved_SettleAtContactDistance()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine();
|
||||||
|
|
||||||
|
// Two creatures 2 m apart (well clear), each catching up toward the SAME centre point
|
||||||
|
// (10,10) — if either reached it they would coincide (full overlap). The sweep must stop
|
||||||
|
// each short at contact-distance from the other.
|
||||||
|
uint idA = 0xA1u, idB = 0xB2u;
|
||||||
|
var target = new Vector3(10f, 10f, GroundZ);
|
||||||
|
var a = GroundedBody(new Vector3(9f, 10f, GroundZ));
|
||||||
|
var b = GroundedBody(new Vector3(11f, 10f, GroundZ));
|
||||||
|
RegisterCreatureAt(engine, idA, a.Position);
|
||||||
|
RegisterCreatureAt(engine, idB, b.Position);
|
||||||
|
uint cellA = Cell, cellB = Cell;
|
||||||
|
|
||||||
|
float sepAt = 0f;
|
||||||
|
for (int tick = 0; tick < 250; tick++)
|
||||||
|
{
|
||||||
|
// Creature A: catch-up + sweep against B's CURRENT shadow, then sync A's shadow to
|
||||||
|
// the resolved body (retail: change_cell from the resolved m_position).
|
||||||
|
var pa = StepToward(engine, idA, a, ref cellA, target);
|
||||||
|
engine.ShadowObjects.UpdatePosition(idA, pa, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA);
|
||||||
|
|
||||||
|
var pb = StepToward(engine, idB, b, ref cellB, target);
|
||||||
|
engine.ShadowObjects.UpdatePosition(idB, pb, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB);
|
||||||
|
|
||||||
|
float s = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y));
|
||||||
|
if (tick == 200) sepAt = s; // separation snapshot 50 ticks before the end
|
||||||
|
if (tick % 50 == 0 || tick == 249)
|
||||||
|
_out.WriteLine($"tick{tick,3}: A=({a.Position.X:F2},{a.Position.Y:F2}) " +
|
||||||
|
$"B=({b.Position.X:F2},{b.Position.Y:F2}) sep={s:F3}");
|
||||||
|
}
|
||||||
|
|
||||||
|
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y),
|
||||||
|
new(b.Position.X, b.Position.Y));
|
||||||
|
_out.WriteLine($"with-sync final sep={sep:F3} m (contact-distance = {ContactDist:F2} m)");
|
||||||
|
// The sweep de-overlaps them to a stable equilibrium ~0.10 m inside touching-distance
|
||||||
|
// ("barely overlapping" — the retail look), from a start where, without the sync, they
|
||||||
|
// fully coincide. Assert: clearly de-overlapped (>= 0.80 m, i.e. within ~0.16 of contact) ...
|
||||||
|
Assert.True(sep >= ContactDist - 0.16f,
|
||||||
|
$"converging creatures must de-overlap to near contact-distance; got {sep:F3} m (contact {ContactDist:F2})");
|
||||||
|
// ... and STABLE (not collapsing back into overlap over the last 50 ticks).
|
||||||
|
Assert.True(MathF.Abs(sep - sepAt) < 0.02f,
|
||||||
|
$"the de-overlapped separation must be stable; drifted {sepAt:F3} -> {sep:F3}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConvergingCreatures_WithoutShadowSync_Overlap_ProvingTheSyncIsLoadBearing()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine();
|
||||||
|
|
||||||
|
uint idA = 0xA1u, idB = 0xB2u;
|
||||||
|
var target = new Vector3(10f, 10f, GroundZ);
|
||||||
|
var a = GroundedBody(new Vector3(9f, 10f, GroundZ));
|
||||||
|
var b = GroundedBody(new Vector3(11f, 10f, GroundZ));
|
||||||
|
RegisterCreatureAt(engine, idA, a.Position);
|
||||||
|
RegisterCreatureAt(engine, idB, b.Position);
|
||||||
|
uint cellA = Cell, cellB = Cell;
|
||||||
|
|
||||||
|
// SAME convergence, but the shadow is NEVER re-synced to the resolved body — it stays at
|
||||||
|
// each creature's START position (the "shadow at server/stale truth" bug). Each creature
|
||||||
|
// sweeps against where its neighbour WAS, not where it IS, so nothing stops them reaching
|
||||||
|
// the shared centre => they end up overlapping.
|
||||||
|
for (int tick = 0; tick < 250; tick++)
|
||||||
|
{
|
||||||
|
StepToward(engine, idA, a, ref cellA, target);
|
||||||
|
StepToward(engine, idB, b, ref cellB, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y),
|
||||||
|
new(b.Position.X, b.Position.Y));
|
||||||
|
_out.WriteLine($"no-sync final sep={sep:F3} m (contact-distance = {ContactDist:F2} m)");
|
||||||
|
// Without the sync each creature sweeps against its neighbour's STALE start-position
|
||||||
|
// shadow, so nothing stops them reaching the shared centre => they pack well inside
|
||||||
|
// contact-distance. This is the load-bearing contrast with the synced test above.
|
||||||
|
Assert.True(sep < 0.40f,
|
||||||
|
$"without the shadow-follows-resolved sync the creatures should heavily OVERLAP (< 0.40 m — both reach " +
|
||||||
|
$"the shared centre because each sweeps only its neighbour's stale start-shadow); got {sep:F3} m — " +
|
||||||
|
$"if this fails the sync may not be the mechanism, rethink before wiring");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration test through the PRODUCTION catch-up driver (the #184 review's
|
||||||
|
/// finding-1 gap): the earlier mechanism tests use a synthetic fixed step, but
|
||||||
|
/// production drives the body from <see cref="RemoteMotionCombiner.ComputeOffset"/>
|
||||||
|
/// → <see cref="InterpolationManager.AdjustOffset"/>, which has a STALL-BLIP: when
|
||||||
|
/// the body is blocked short of an OVERLAPPING server waypoint (exactly the
|
||||||
|
/// de-overlap equilibrium) it makes no progress, node_fail_counter climbs past 3
|
||||||
|
/// over ~5-frame windows, and it fires a blip-to-tail (a jump straight AT the
|
||||||
|
/// overlap target) then clears the queue. The concern: if the per-tick sweep does
|
||||||
|
/// not absorb that blip, the monster pops into its neighbour a few times a second.
|
||||||
|
/// This drives the REAL loop (Enqueue at UP cadence + ComputeOffset + sweep +
|
||||||
|
/// shadow-sync) for two creatures converging on a shared point and asserts BOTH
|
||||||
|
/// that they de-overlap AND that no single tick's net move spikes (the sweep
|
||||||
|
/// absorbs every blip — matching retail, which runs the same interp + collision).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ConvergingCreatures_RealInterpLoop_DeOverlapsAndAbsorbsTheStallBlip()
|
||||||
|
{
|
||||||
|
var engine = BuildEngine();
|
||||||
|
uint idA = 0xA1u, idB = 0xB2u;
|
||||||
|
var target = new Vector3(10f, 10f, GroundZ); // shared point → coincide if reached
|
||||||
|
var a = GroundedBody(new Vector3(9f, 10f, GroundZ));
|
||||||
|
var b = GroundedBody(new Vector3(11f, 10f, GroundZ));
|
||||||
|
RegisterCreatureAt(engine, idA, a.Position);
|
||||||
|
RegisterCreatureAt(engine, idB, b.Position);
|
||||||
|
uint cellA = Cell, cellB = Cell;
|
||||||
|
|
||||||
|
var interpA = new InterpolationManager();
|
||||||
|
var interpB = new InterpolationManager();
|
||||||
|
var combA = new RemoteMotionCombiner();
|
||||||
|
var combB = new RemoteMotionCombiner();
|
||||||
|
const float maxSpeed = 4f; // motion-table max speed → catch-up ≤ 2× = 8 m/s
|
||||||
|
const float dt = 1f / 60f;
|
||||||
|
const int upEvery = 10; // ~6 Hz UpdatePosition cadence
|
||||||
|
float maxSpikeA = 0f, maxSpikeB = 0f;
|
||||||
|
|
||||||
|
for (int tick = 0; tick < 600; tick++) // 10 s — long enough for many stall-blip cycles
|
||||||
|
{
|
||||||
|
if (tick % upEvery == 0)
|
||||||
|
{
|
||||||
|
// "UpdatePosition": the server keeps reporting the (overlapping) target —
|
||||||
|
// MoveOrTeleport near-branch enqueues it for the catch-up to chase.
|
||||||
|
interpA.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: a.Position);
|
||||||
|
interpB.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: b.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
var preA = a.Position;
|
||||||
|
a.Position += combA.ComputeOffset(dt, a.Position, Vector3.Zero, a.Orientation, interpA, maxSpeed);
|
||||||
|
var rA = engine.ResolveWithTransition(preA, a.Position, cellA, R, H, StepUp, StepDown,
|
||||||
|
isOnGround: true, body: a, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idA);
|
||||||
|
a.Position = rA.Position; if (rA.CellId != 0) cellA = rA.CellId;
|
||||||
|
engine.ShadowObjects.UpdatePosition(idA, a.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA);
|
||||||
|
|
||||||
|
var preB = b.Position;
|
||||||
|
b.Position += combB.ComputeOffset(dt, b.Position, Vector3.Zero, b.Orientation, interpB, maxSpeed);
|
||||||
|
var rB = engine.ResolveWithTransition(preB, b.Position, cellB, R, H, StepUp, StepDown,
|
||||||
|
isOnGround: true, body: b, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idB);
|
||||||
|
b.Position = rB.Position; if (rB.CellId != 0) cellB = rB.CellId;
|
||||||
|
engine.ShadowObjects.UpdatePosition(idB, b.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB);
|
||||||
|
|
||||||
|
// Ignore the initial approach (they start 2 m apart and legitimately move
|
||||||
|
// ~0.13 m/tick); measure the per-tick net move only once they are near the
|
||||||
|
// equilibrium where the stall-blip fires.
|
||||||
|
if (tick > 120)
|
||||||
|
{
|
||||||
|
maxSpikeA = MathF.Max(maxSpikeA, Vector2.Distance(new(a.Position.X, a.Position.Y), new(preA.X, preA.Y)));
|
||||||
|
maxSpikeB = MathF.Max(maxSpikeB, Vector2.Distance(new(b.Position.X, b.Position.Y), new(preB.X, preB.Y)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y));
|
||||||
|
_out.WriteLine($"real-interp: sep={sep:F3} m, maxSpike A={maxSpikeA:F3} B={maxSpikeB:F3}");
|
||||||
|
Assert.True(sep >= ContactDist - 0.16f,
|
||||||
|
$"real-loop converging creatures must de-overlap to near contact-distance; got {sep:F3} m");
|
||||||
|
// The stall-blip must be absorbed by the sweep every time — no tick jumps the body
|
||||||
|
// more than a large catch-up step (8 m/s × dt ≈ 0.13 m; allow generous headroom).
|
||||||
|
Assert.True(maxSpikeA < 0.30f && maxSpikeB < 0.30f,
|
||||||
|
$"a stall-blip escaped the sweep (monster popped into its neighbour): " +
|
||||||
|
$"maxSpike A={maxSpikeA:F3} B={maxSpikeB:F3} m (limit 0.30)");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue