From ddb5a96799ed5ee14e63317681a53620c523c7a9 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 8 Jul 2026 00:45:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(#184):=20Slice=202b=20=E2=80=94=20unify=20?= =?UTF-8?q?the=20remote=20player/NPC=20fork=20(players=20collide=20faithfu?= =?UTF-8?q?lly)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two-path fork in RemotePhysicsUpdater.Tick: the former Path A (grounded PLAYER remotes advanced by the interp catch-up with ResolveWithTransition deliberately OMITTED, per the now-retired issue-#40 premise) is gone. Every remote -- player and NPC -- now runs the SAME per-tick catch-up + sweep + shadow-follows-resolved. Retail's UpdateObjectInternal (0x005156b0) has no player/remote fork; this is the faithful shape. Player remotes now get terrain-Z snap (no slope staircase), wall collision, and MONSTER collision -- previously (Path A) they skipped ALL collision. RETAIL PvP (adversarial review caught this): two non-PK players WALK THROUGH each other in AC (you can stand inside another non-PK player) -- they do NOT de-overlap. The remote-player mover now carries IsPlayer|EdgeSlide (mirroring the LOCAL player at PlayerMovementController), so CollisionExemption's PvP block exempts a non-PK pair, exactly as retail sets IsPlayer on every object's own transition (OBJECTINFO::init 0x0050cf30) and FindObjCollisions (pc:276812) exempts it. The first 2b draft passed bare EdgeSlide and de-overlapped players (MORE solid than retail); the 3-lens review flagged it. PK/PKLite/Impenetrable are not plumbed onto the remote mover yet -- the same M1.5 gap the local player carries (TS-23, extended). #40 proven dead in code (before the gate): PlayerVsMonster_DeOverlapsAndAbsorbsTheStallBlip drives the real ComputeOffset -> InterpolationManager catch-up (incl. the fail_count blip-to-tail) for a player mover converging on a monster and asserts de-overlap + maxSpike<0.30 -- the sweep absorbs the stall-blip. ConvergingPlayers_WalkThroughEachOther proves the PvP exemption (non-PK players pass through). Path B already ran the #40-feared config stably; #40 (May 2026) predates the CSphere/#137/#170/#171 rebuild. Placement (HIGH, review finding 3): the player UP routing gains the SAME placement-snap backstop Slice 1 gave NPCs (AP-87). Without it a UM-first player (RemoteMotion seeded to the spawn pos, then a first UP in a different cell) would sweep from a stale cell -> garbage -> the digest's invisible/misplaced-player bug. The 4 m bodyToTarget guard + !willBeDrTicked + dist>96 snap; near placed corrections still enqueue for smooth catch-up. Also seed Body.Position=worldPos at UP-handler RemoteMotion creation (mirrors the UM handler :5176) for the UP-first case. Coupled shadow edits (research finding 9): RETIRED the players-only raw-worldPos shadow sync -- now that players run the sweep + shadow-follows-resolved, the raw sync would re-snap a packed player's shadow into overlap each UP. Player shadows follow the RESOLVED body via the DR-tick loop + a new player UP-branch-tail SyncRemoteShadowToBody. Surviving player/NPC split (AP-88): the omega -- grounded PLAYERS keep the ObservedOmega-or-seqOmega world-frame (Concatenate) fallback ("rectangle when running circles"); NPCs + airborne keep ObservedOmega-only body-frame (Multiply). They commute for an upright body + yaw omega, so the fork is faithful. Register: TS-23 extended (remote-player mover PK gap); AP-86 updated (raw sync retired for players too, Where column fixed); AP-88 added (omega fork + eval-order note). Tests: Core 2623 / App 741 green, 0 warnings. 3-lens adversarial review + per-finding verification (10 agents); all 6 confirmed findings addressed (2 substantive: PvP mover flags + player placement-snap; 4 doc/cosmetic). VISUAL GATE (acceptance test) owed by the user -- NOTE the corrected expectation: (a) a player remote on a hill -- no slope staircase; (b) two packed player remotes -- they WALK THROUGH each other (retail PvP), NOT de-overlap (this corrects the design's original "players de-overlap" gate); (c) a player remote cannot stand inside a MONSTER (new: player-vs-monster collision); (d) remote walk/run/jump/land/turn UNCHANGED. Handoff: docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. Co-Authored-By: Claude Opus 4.8 --- .../retail-divergence-register.md | 7 +- .../Physics/RemotePhysicsUpdater.cs | 360 ++++-------------- src/AcDream.App/Rendering/GameWindow.cs | 94 +++-- .../Physics/RemoteDeOverlapMechanismTests.cs | 151 ++++++++ 4 files changed, 303 insertions(+), 309 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bace6a1f..ba5c2824 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 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) +## 3. Documented approximation (AP) — 77 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; AP-88 added 2026-07-08 — the remote omega player/NPC fork surviving #184 Slice 2b's fork collapse; AP-86 updated same day — the per-UP raw-pos shadow sync is now retired for players too) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -188,8 +188,9 @@ 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-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-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-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 is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `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) | +| AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) | ## 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) @@ -215,7 +216,7 @@ accepted-divergence entries (#96, #49, #50). | TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) | | TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | -| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1177` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 | +| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.App/Input/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) | | TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 | | TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 | | TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 | diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 72c0ef55..ef0d2b13 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -12,12 +12,16 @@ namespace AcDream.App.Physics; /// (ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid /// && rm.LastServerPosTime > 0). /// -/// Behaviour is byte-for-byte the pre-extraction body: the player/NPC -/// FORK is PRESERVED here unchanged — Path A (grounded PLAYER remotes, -/// IsPlayerGuid(serverGuid) && !rm.Airborne) advances by the interp -/// catch-up and deliberately OMITS the sweep; Path B (NPCs + airborne player -/// remotes) runs ResolveWithTransition + shadow-follows-resolved. Slice -/// 2b collapses the fork inside this class. See +/// Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED +/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the +/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue +/// #40 premise) is gone — every remote now runs the SAME catch-up + +/// ResolveWithTransition sweep + shadow-follows-resolved, so packed PLAYER +/// remotes de-overlap exactly like NPCs (retail UpdateObjectInternal +/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is +/// the omega handling (players keep the ObservedOmega||seqOmega world-frame +/// fallback; NPCs + airborne bodies use ObservedOmega-only body-frame) and +/// the !IsPlayerGuid-gated stale-velocity anim-cycle stop. See /// docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. /// /// Shared helpers that GameWindow also calls elsewhere are injected: @@ -72,269 +76,21 @@ internal sealed class RemotePhysicsUpdater // TickRemoteMoveTo). No-op for entities with no target + no voyeurs. rm.Host?.HandleTargetting(); - if (IsPlayerGuid(serverGuid) && !rm.Airborne) + // #184 Slice 2b — the UNIFIED per-remote tick. The former Path A + // (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition + // sweep OMITTED, per the now-retired issue-#40 "collision is the sender's + // problem" premise) is GONE — every remote now runs the SAME catch-up + + // sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap + // exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO + // player/remote fork; the only surviving player/NPC split is the omega + // handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop. + // + // Stop detection stays explicit on packet receipt (UpdateMotion + // ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared -> + // StopCompletely). Mirrors retail update_object -> UpdatePositionInternal + // -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0). + // The bare block scopes this update's locals (formerly the else body). { - // ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ── - // - // Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md + - // 04-interp-manager.md + - // 05-position-manager-and-partarray.md): - // - // - For a grounded REMOTE player, m_velocityVector stays at 0. - // - apply_current_movement is NEVER called per tick on remotes - // (it's the local-player-only velocity feed). - // - UpdatePhysicsInternal's translation step is gated on - // velocity² > 0, so it's a no-op when body.Velocity = 0. - // - ResolveWithTransition is NOT called — the server already - // collision-resolved the broadcast position. - // - Per-tick body translation per retail UpdatePositionInternal: - // 1. CPartArray::Update writes anim root motion (body-local - // seqVel × dt) into the local frame. - // 2. PositionManager::adjust_offset OVERWRITES the local - // frame's origin with the queue catch-up vector when - // the queue is active and the head is not yet reached - // — REPLACE, not additive. - // 3. Frame::combine composes the local frame with the - // body's world pose. - // Net: catch-up replaces anim during the chase phase, anim - // stands when the queue is empty / head reached. PositionManager. - // ComputeOffset implements this exact REPLACE dichotomy. - // - // Airborne player remotes (rm.Airborne) and NPCs fall through to - // the legacy path below — unchanged from main per the M2 plan. - System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity - ?? System.Numerics.Vector3.Zero; - System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega - ?? System.Numerics.Vector3.Zero; - - // Step 1: transient flags (Contact + OnWalkable for grounded; - // Active always so UpdatePhysicsInternal doesn't early-return). - if (!rm.Airborne) - { - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact - | AcDream.Core.Physics.TransientStateFlags.OnWalkable - | AcDream.Core.Physics.TransientStateFlags.Active; - - // For grounded remotes the body should not be carrying - // velocity — retail's m_velocityVector for a remote is - // 0 unless the server explicitly pushed one. Clear any - // stale velocity from a prior airborne arc so - // UpdatePhysicsInternal doesn't double-apply it on top - // of the seqVel-driven ComputeOffset translation below. - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - else - { - rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; - } - - // R4-V5 glide fix (2026-07-03 user report: a retail - // player using an object GLIDES to it — position moves - // via the UP queue but no walk/run legs): remote - // PLAYERS' MoveToManagers were armed by mt-6 but never - // TICKED — the V4 UseTime slot lived only in the - // NPC/legacy branch below, gated !IsPlayerGuid - // (inherited from the deleted RemoteMoveToDriver's - // NPC-only scope). Retail ticks every entity's - // MovementManager (UpdateObjectInternal has no entity- - // class fork). The manager's dispatches produce the - // locomotion cycle through the funnel sink (the LEGS); - // position stays queue-chased per the L.3 M2 spec, so - // the two compose exactly like an NPC's tick. - TickRemoteMoveTo(rm); - - // Step 2 (M3): queue + anim translation via PositionManager. - // ComputeOffset returns: - // - Vector3.Zero when queue is empty AND seqVel is zero - // (idle remote between UPs after head reached) — body - // stays still. - // - Direction × min(catchUpSpeed × dt, dist) when the - // queue is active and head is not reached — body chases - // the head waypoint at up to 2× motion-table max speed - // (REPLACES anim for this frame). - // - Anim root motion (seqVel × dt rotated into world) when - // the queue is empty OR head is within DesiredDistance — - // body advances with the locomotion cycle's baked - // velocity, keeping legs and body pace synchronized. - // - Blip-to-tail (tail − body) when fail_count > 3. - float maxSpeed = rm.Motion.GetMaxSpeed(); - // Slope-staircase fix (2026-05-05): sample terrain normal - // at the body's current XY so PositionManager can project - // the seqVel-only fallback onto the local slope. Without - // this, the queue-empty interval between UPs left Z flat - // (anim cycles bake Z=0 body-local) — visible ~5 Hz - // staircase when a remote runs up/down hills. The - // projection is a no-op on flat ground. - System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal( - rm.Body.Position.X, rm.Body.Position.Y); - - System.Numerics.Vector3 bodyPosBefore = rm.Body.Position; - System.Numerics.Vector3 offset = rm.Position.ComputeOffset( - dt: (double)dt, - currentBodyPosition: rm.Body.Position, - seqVel: seqVel, - ori: rm.Body.Orientation, - interp: rm.Interp, - maxSpeed: maxSpeed, - terrainNormal: terrainNormal); - // R5-V3 (#171): retail chains Interpolation → Sticky over - // ONE shared delta frame (PositionManager::adjust_offset - // 0x00555190). The combiner's catch-up IS acdream's - // interpolation stage, so its offset SEEDS the frame - // (converted to mover-local) and StickyManager:: - // adjust_offset OVERWRITES it when armed+initialized - // (0x00555430 assigns m_fOrigin rather than accumulating). - // With no stick armed the frame comes back untouched and - // this reduces to the pre-V3 `Position += offset`. - if (rm.Host is { } plHost) - { - var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame - { - Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( - rm.Body.Orientation, offset), - }; - plHost.PositionManager.AdjustOffset(pmDelta, dt); - ApplyPositionManagerDelta(rm.Body, pmDelta); - } - else - { - rm.Body.Position += offset; - } - - // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1. - // Prints per-tick body Z trajectory + queue state + projected - // offset.Z so we can grep before/after the fix and confirm Z - // changes continuously between UPs on slopes (no flat - // intervals followed by snaps). - if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1") - { - bool queueActive = rm.Interp.IsActive; - float nz = terrainNormal?.Z ?? 1.0f; - System.Console.WriteLine( - $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} " - + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) " - + $"queue={queueActive} cpN.Z={nz:F3}"); - } - - // Step 2.5: angular velocity → body orientation. Prefer - // ObservedOmega (set explicitly in OnLiveMotionUpdated from - // the wire's TurnCommand + signed TurnSpeed) over the - // sequencer's synthesized omega: when the player runs in - // a circle ACE broadcasts ForwardCommand=RunForward AND - // TurnCommand=TurnLeft on the same UpdateMotion. The - // sequencer's animCycle picker chooses RunForward (legs - // running), whose synthesized CurrentOmega is zero. Body - // would not rotate between UPs and body.Velocity stays in - // an out-of-date world direction, producing the - // user-reported "rectangle when running circles" effect. - // ObservedOmega has the correct turn rate even when the - // visible cycle is RunForward. - System.Numerics.Vector3 omegaToApply = - rm.ObservedOmega.LengthSquared() > 1e-9f - ? rm.ObservedOmega - : seqOmega; - if (omegaToApply.LengthSquared() > 1e-9f) - { - float angleDelta = omegaToApply.Length() * (float)dt; - System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply); - var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta); - rm.Body.Orientation = System.Numerics.Quaternion.Normalize( - System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot)); - - // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction - // once per remote per ~1 second so we can confirm whether the omega - // sign actually being applied matches the retail-observed turn - // direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight). - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (nowSec - rm.LastOmegaDiagLogTime > 0.5) - { - uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0; - System.Console.WriteLine( - $"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} " - + $"omegaApplied.Z={omegaToApply.Z:F3} " - + $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) " - + $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)"); - rm.LastOmegaDiagLogTime = nowSec; - } - } - } - - // Step 3: calc_acceleration sets body.Acceleration from the Gravity flag - // (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called - // per-frame in update_object). Without this, body.Acceleration stays stale - // or zero → gravity never decays jump velocity → endless rise on jumps. - rm.Body.calc_acceleration(); - - // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). - rm.Body.UpdatePhysicsInternal(dt); - - // Step 4b INTENTIONALLY OMITTED in M2: - // ResolveWithTransition is NOT called — the server has - // already collision-resolved the broadcast position, and - // running our sweep on tiny per-frame queue catch-up deltas - // amplifies micro-bounces into visible position blips - // (issue #40 staircase + flat-ground blips). Per retail - // spec the per-tick body advance for a remote is purely - // the queue catch-up; collision is the sender's problem. - // - // Step 5 (landing fallback) is unreachable in this branch — - // we're gated on !rm.Airborne. Airborne player remotes fall - // through to the legacy path below where K-fix15 still fires. - - // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1). - // Track the maximum sequencer velocity magnitude seen since - // the last UpdatePosition arrival (carried on the - // RemoteMotion struct), then on each UP arrival the - // OnLivePositionUpdated path prints the comparison against - // the server's actual broadcast pace - // ((LastServerPos - PrevServerPos) / Δt). This guarantees - // both sides are sampled during the same window and the - // ratio reflects the real overshoot. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - // body.Velocity is now the source of bulk translation - // (set above by apply_current_movement). Track its - // magnitude so VEL_DIAG can compare against the actual - // server broadcast pace. - float seqSpeedNow = rm.Body.Velocity.Length(); - if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP) - rm.MaxSeqSpeedSinceLastUP = seqSpeedNow; - } - - ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty - if (rm.CellId != 0) - ae.Entity.ParentCellId = rm.CellId; - ae.Entity.Rotation = rm.Body.Orientation; - } - else - { - // ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ── - // - // Stop detection is handled explicitly on packet receipt: - // - UpdateMotion with ForwardCommand flag CLEARED → Ready. - // - UpdatePosition with HasVelocity flag CLEARED → StopCompletely. - // Both map to retail's "flag-absent = Invalid = reset to - // default" semantics (FUN_0051F260 bulk-copy). No timer-based - // inference needed — the server sends the right signal every - // time a remote stops. - - // Retail per-tick motion pipeline applied to every remote. - // Mirrors retail FUN_00515020 update_object → FUN_00513730 - // UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal: - // - // 1. apply_current_movement (FUN_00529210) — recomputes - // body.Velocity from InterpretedState via get_state_velocity. - // 2. Pull omega from the sequencer (baked MotionData.Omega - // for TurnRight / TurnLeft cycles, scaled by speedMod). - // 3. body.update_object(now) — Euler-integrates - // position += Velocity × dt + 0.5 × Accel × dt² AND - // orientation += omega × dt. - // - // On UpdatePosition receipt we hard-snap body.Position and - // body.Orientation — if integration matched server physics, - // each snap is small/invisible. double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; // Step 1: re-apply current motion commands → body.Velocity. @@ -367,8 +123,8 @@ internal sealed class RemotePhysicsUpdater // (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity): // retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity // leg — a remote translates by adjust_offset and the UP is a gentle - // target. Matches Path A's grounded model (:10113); clearing - // velocity mirrors Path A (:10125). + // target. As of #184 Slice 2b this grounded model is the SINGLE + // remote path (players + NPCs) — retail has no fork. rm.Body.Velocity = System.Numerics.Vector3.Zero; // Stale server-velocity → stop the locomotion CYCLE (the legs). @@ -427,8 +183,37 @@ internal sealed class RemotePhysicsUpdater // ObservedOmega derived from server UP rotation deltas so // the rate exactly matches server physics — hard-snap on // next UP becomes invisible by construction. - rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object - if (rm.ObservedOmega.LengthSquared() > 1e-8f) + // #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback + + // world-frame (pre-multiply, Concatenate) application inherited from the + // former Path A — a circling player sends RunForward+TurnLeft on ONE UM + // whose RunForward cycle synthesises zero omega, so ObservedOmega (from + // the wire TurnCommand) must carry the turn or the body would not rotate + // between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies + // keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a + // seqOmega fallback would change NPC turning (handoff 4.1), so the split + // is preserved. For an upright body + a yaw (world-Z) omega the two + // multiplication orders commute, so this fork is faithful, not cosmetic. + // calc_acceleration zeroes Body.Omega for grounded bodies before + // UpdatePhysicsInternal; the explicit zero here covers the airborne case + // (a wire-set Body.Omega would otherwise double-integrate on top of the + // manual rotation). + rm.Body.Omega = System.Numerics.Vector3.Zero; + if (IsPlayerGuid(serverGuid) && !rm.Airborne) + { + System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega + ?? System.Numerics.Vector3.Zero; + System.Numerics.Vector3 omegaToApply = + rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega; + if (omegaToApply.LengthSquared() > 1e-9f) + { + float angleDelta = omegaToApply.Length() * (float)dt; + System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply); + var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta); + rm.Body.Orientation = System.Numerics.Quaternion.Normalize( + System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot)); + } + } + else if (rm.ObservedOmega.LengthSquared() > 1e-8f) { float omegaMag = rm.ObservedOmega.Length(); var axis = rm.ObservedOmega / omegaMag; @@ -580,10 +365,26 @@ internal sealed class RemotePhysicsUpdater // we hit on the local jump). isOnGround: !rm.Airborne, body: rm.Body, // persist ContactPlane across frames for slope tracking - // Retail default physics state includes EdgeSlide. - // Remote dead-reckoning should exercise the same - // edge/cliff branch as local movement. - moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + // Retail default physics state includes EdgeSlide; remote DR + // should exercise the same edge/cliff branch as local movement. + // #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so + // CollisionExemption's PvP block fires exactly as it does for the + // LOCAL player (PlayerMovementController :920) — two non-PK players + // WALK THROUGH each other (retail sets IsPlayer on every object's + // own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100` + // from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a + // non-PK player pair). Without IsPlayer the mover would de-overlap + // two players — MORE solid than retail (you can stand inside another + // non-PK player in AC). Players still COLLIDE with monsters (target + // not IsPlayer → no exemption) + terrain + walls. PK/PKLite/ + // Impenetrable are NOT plumbed onto the remote mover yet, so a PK + // pair walks through where retail collides — the SAME M1.5 gap the + // local player carries (see TS-23; PlayerDescription PK status + // unparsed). + moverFlags: IsPlayerGuid(serverGuid) + ? AcDream.Core.Physics.ObjectInfoState.IsPlayer + | AcDream.Core.Physics.ObjectInfoState.EdgeSlide + : AcDream.Core.Physics.ObjectInfoState.EdgeSlide, // Fix #42 (2026-05-05): skip the moving remote's // own ShadowEntry. _animatedEntities is keyed by // entity.Id so kv.Key matches the EntityId the @@ -613,9 +414,10 @@ internal sealed class RemotePhysicsUpdater // 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- + // SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for + // players too — every remote's shadow (player + NPC) is written ONLY + // by 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 diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f06d9d91..18804ab9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5654,21 +5654,17 @@ public sealed class GameWindow : IDisposable // (its body is the simulator, not a target). Retail does the // equivalent via SetPosition → change_cell → AddShadowObject // (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862). - 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 - // (retail SetPosition → calc_cross_cells from m_position). - _physicsEngine.ShadowObjects.UpdatePosition( - entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId, - seedCellId: p.LandblockId); - } + // #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED. + // It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped + // the sweep and tracked the server position closely. Now that Slice 2b runs + // the SAME per-tick sweep + shadow-follows-resolved for players, writing the + // raw (overlapping) server pos here would re-snap a packed player's shadow + // into overlap once per UP and fight the in-tick de-overlap (research + // finding 9). Player shadows now follow the RESOLVED body — via the DR-tick + // loop (SyncRemoteShadowToBody, movement-gated) and the player UP-branch tail + // below (first-UP / no-Sequencer case), exactly like NPCs. Local-player + // broadphase still tests an up-to-date remote shadow; it is just the resolved + // body now, not the raw wire pos. // Track remote-entity motion for stop detection. Only record the // timestamp when position moved MEANINGFULLY (> 0.05m). Updates @@ -5710,6 +5706,18 @@ public sealed class GameWindow : IDisposable // Hard-snap orientation on first spawn so the per-tick // slerp doesn't visibly rotate from Identity to truth. rmState.Body.Orientation = rot; + // #184 Slice 2b: PLACE the body at the server position on creation, + // mirroring the UM handler's seed (:5176 `Body.Position = + // entity.Position`). A UP-first RemoteMotion (created here before any + // UM) was left at the default (0,0,0). Path A never swept, so that + // stale origin was harmless — it caught up gradually. Now that Slice + // 2b runs the sweep for grounded PLAYERS too, an unplaced body would + // sweep from (0,0,0) in the server cell that does not contain it → + // garbage resolved pos → the digest's INVISIBLE/misplaced-body bug. + // Seeding here is the root-cause fix (the UP creation path should + // seed exactly like the UM path); worldPos == entity.Position (the + // unconditional snap at the top of this handler already ran). + rmState.Body.Position = worldPos; } // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for @@ -5819,25 +5827,45 @@ public sealed class GameWindow : IDisposable } // ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ──────────── - const float MaxPhysicsDistance = 96f; + const float MaxPhysicsDistance = 96f; // retail player_distance far-snap + const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero; float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos); + // #184 Slice 2b: the player UP routing gains the SAME placement-snap + // backstop the NPC routing got in Slice 1 (AP-87). Now that grounded + // PLAYER remotes run the sweep, an unplaced / stale-cell body — a + // UM-first RemoteMotion seeded to the spawn pos (:5176) then a first UP + // in a DIFFERENT cell, which the UP-creation seed (:5720) does NOT cover + // — would enqueue and the per-tick sweep would run from a cell that does + // not contain the body -> garbage resolved pos -> the digest's + // INVISIBLE/misplaced player. The 4 m bodyToTarget guard is the + // LOAD-BEARING backstop (AP-87; firstUp via LastServerPosTime is a poor + // signal for players — it is already set by the VEL_DIAG block above); + // !willBeDrTicked snaps a no-Sequencer player whose queue nothing would + // consume; dist>96 is retail's far-snap. Placed + near corrections still + // enqueue for the smooth catch-up. + float bodyToTarget = System.Numerics.Vector3.Distance( + rmState.Body.Position, worldPos); + bool willBeDrTicked = + _animatedEntities.TryGetValue(entity.Id, out var aeDrPl) + && aeDrPl.Sequencer is not null; - if (dist > MaxPhysicsDistance) + if (dist > MaxPhysicsDistance || !willBeDrTicked + || bodyToTarget > BodySnapThreshold) { - // Beyond view bubble: SetPositionSimple slide-snap. Clear queue. + // Beyond view bubble / large correction / unplaced body: + // SetPositionSimple slide-snap. Clear queue. rmState.Interp.Clear(); rmState.Body.Position = worldPos; } else { - // Within view bubble: enqueue waypoint for adjust_offset to walk to. - // The per-frame TickAnimations player-remote path drives the - // actual body advancement via InterpolationManager.AdjustOffset. - // Pass body's current position so the InterpolationManager can - // detect a far-distance enqueue (>100 m from body) and pre-arm - // an immediate blip — avoids body drifting visibly toward a - // far waypoint instead of teleporting to it. + // Within view bubble, placed + near: enqueue waypoint for + // adjust_offset to walk to. The per-frame TickAnimations player- + // remote path drives the actual body advancement via + // InterpolationManager.AdjustOffset. Pass body's current position so + // the InterpolationManager can detect a far-distance enqueue (>100 m + // from body) and pre-arm an immediate blip. float headingFromQuat = ExtractYawFromQuaternion(rot); rmState.Interp.Enqueue( worldPos, @@ -5871,6 +5899,18 @@ public sealed class GameWindow : IDisposable // the near-enqueue branch this prevents a 1-frame teleport-then- // yank-back rubber-band as TickAnimations chases worldPos via the // queue. + // + // #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed + // body (mirrors the NPC UP-branch tail). Now that grounded players run + // the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync + // (top of this handler) would have re-snapped the shadow into overlap + // each UP; this keeps collision == render for the first UP (before any + // DR tick) and for no-Sequencer players. rmState.CellId is the server + // cell adopted above (:5735). The per-tick loop keeps it current + // between UPs (movement-gated). + if (rmState.CellId != 0) + _remotePhysicsUpdater.SyncRemoteShadowToBody( + entity.Id, rmState, _liveCenterX, _liveCenterY); entity.SetPosition(rmState.Body.Position); return; } @@ -6105,8 +6145,8 @@ public sealed class GameWindow : IDisposable } // #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. + // server pos — the raw-pos sync was RETIRED for players too in Slice 2b) + // so collision == render and the de-overlap isn't snapped away 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. diff --git a/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs index 3c5716e7..b86e25e3 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs @@ -64,6 +64,16 @@ public class RemoteDeOverlapMechanismTests 0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, EntityCollisionFlags.IsCreature, isStatic: false); + // #184 Slice 2b: a PLAYER remote's shadow — flagged IsPlayer (BF_PLAYER, + // 0x8 → EntityCollisionFlags.IsPlayer) and IsCreature (a player IS an + // ItemType.Creature). Non-PK (no IsPK/IsPKLite), so the PvP walk-through + // exemption's only live disqualifier would be an IsPlayer *mover* — which + // the production remote sweep never sets (it passes moverFlags: EdgeSlide). + private static void RegisterPlayerAt(PhysicsEngine e, uint id, Vector3 c) + => e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R, + 0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, + EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsCreature, isStatic: false); + private static PhysicsBody GroundedBody(Vector3 pos) => new PhysicsBody { Position = pos, @@ -303,4 +313,145 @@ public class RemoteDeOverlapMechanismTests Assert.True(sep > ContactDist + 0.4f, $"large creatures must spread materially WIDER than the human contact ({ContactDist:F2} m); got {sep:F3} m"); } + + /// + /// #184 Slice 2b — RETAIL PvP: two non-PK PLAYER remotes must WALK THROUGH each + /// other, NOT de-overlap. Slice 2b collapses the remote fork so grounded players + /// run the sweep, but the production remote-player mover carries + /// (mirroring the LOCAL player at + /// PlayerMovementController), so 's PvP + /// block (mover-IsPlayer AND target-IsPlayer, neither PK/PKLite/Impenetrable) + /// EXEMPTS the pair — you can stand inside another non-PK player in AC. Retail + /// sets IsPlayer on every object's own transition (OBJECTINFO::init 0x0050cf30 + /// state |= 0x100 from its weenie IsPlayer()); FindObjCollisions + /// pc:276812 exempts the non-PK pair. This test drives the real interp loop with + /// two IsPlayer movers converging on a shared point and asserts they reach it + /// (pass through, sep < 0.40 m) instead of stopping at contact-distance. The + /// adversarial review of the first 2b draft caught the missing IsPlayer bit — the + /// draft de-overlapped players (MORE solid than retail). + /// + [Fact] + public void ConvergingPlayers_WalkThroughEachOther_PerRetailPvpExemption() + { + var engine = BuildEngine(); + uint idA = 0x50000001u, idB = 0x50000002u; // player guids (0x50…) + var target = new Vector3(10f, 10f, GroundZ); // shared point → coincide if not blocked + var a = GroundedBody(new Vector3(9f, 10f, GroundZ)); + var b = GroundedBody(new Vector3(11f, 10f, GroundZ)); + RegisterPlayerAt(engine, idA, a.Position); + RegisterPlayerAt(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; + const float dt = 1f / 60f; + const int upEvery = 10; + // Production remote-PLAYER mover flags (RemotePhysicsUpdater sweep, IsPlayerGuid branch). + const ObjectInfoState playerMover = ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide; + + for (int tick = 0; tick < 600; tick++) + { + if (tick % upEvery == 0) + { + 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: playerMover, 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: playerMover, 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); + } + + float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); + _out.WriteLine($"player-vs-player (PvP exempt): sep={sep:F3} m (contact {ContactDist:F2})"); + // The PvP exemption fires (both movers IsPlayer, non-PK) → nothing stops them + // reaching the shared centre → they coincide, exactly like the without-sync + // creature case. If this asserts >= contact-distance, the mover lost its + // IsPlayer bit and players are wrongly de-overlapping (MORE solid than retail). + Assert.True(sep < 0.40f, + $"two non-PK player remotes must WALK THROUGH each other (retail PvP exemption); " + + $"got sep={sep:F3} m — if this is near contact-distance the remote mover is missing IsPlayer"); + } + + /// + /// #184 Slice 2b — a PLAYER remote DOES collide with (and de-overlap) a MONSTER, + /// and the #40 stall-blip is absorbed on the IsPlayer mover. This is the other + /// half of the PvP story: the exemption only fires player-vs-player, so a player + /// mover (IsPlayer) vs a creature target (IsCreature, not IsPlayer) proceeds to + /// collision. Drives the real interp loop with a player converging on a monster + /// and asserts (a) they de-overlap to near contact-distance and (b) no tick's net + /// move spikes — proving #40 is dead for the player-mover sweep (the reason Path A + /// omitted it), the property the visual gate can't measure. + /// + [Fact] + public void PlayerVsMonster_DeOverlapsAndAbsorbsTheStallBlip() + { + var engine = BuildEngine(); + uint idPlayer = 0x50000001u, idMonster = 0x80000002u; + var target = new Vector3(10f, 10f, GroundZ); + var p = GroundedBody(new Vector3(9f, 10f, GroundZ)); // player + var m = GroundedBody(new Vector3(11f, 10f, GroundZ)); // monster + RegisterPlayerAt(engine, idPlayer, p.Position); + RegisterCreatureAt(engine, idMonster, m.Position); + uint cellP = Cell, cellM = Cell; + + var interpP = new InterpolationManager(); + var interpM = new InterpolationManager(); + var combP = new RemoteMotionCombiner(); + var combM = new RemoteMotionCombiner(); + const float maxSpeed = 4f; + const float dt = 1f / 60f; + const int upEvery = 10; + const ObjectInfoState playerMover = ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide; + float maxSpikeP = 0f, maxSpikeM = 0f; + + for (int tick = 0; tick < 600; tick++) + { + if (tick % upEvery == 0) + { + interpP.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: p.Position); + interpM.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: m.Position); + } + + var preP = p.Position; + p.Position += combP.ComputeOffset(dt, p.Position, Vector3.Zero, p.Orientation, interpP, maxSpeed); + var rP = engine.ResolveWithTransition(preP, p.Position, cellP, R, H, StepUp, StepDown, + isOnGround: true, body: p, moverFlags: playerMover, movingEntityId: idPlayer); + p.Position = rP.Position; if (rP.CellId != 0) cellP = rP.CellId; + engine.ShadowObjects.UpdatePosition(idPlayer, p.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellP); + + var preM = m.Position; + m.Position += combM.ComputeOffset(dt, m.Position, Vector3.Zero, m.Orientation, interpM, maxSpeed); + var rM = engine.ResolveWithTransition(preM, m.Position, cellM, R, H, StepUp, StepDown, + isOnGround: true, body: m, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idMonster); + m.Position = rM.Position; if (rM.CellId != 0) cellM = rM.CellId; + engine.ShadowObjects.UpdatePosition(idMonster, m.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellM); + + if (tick > 120) + { + maxSpikeP = MathF.Max(maxSpikeP, Vector2.Distance(new(p.Position.X, p.Position.Y), new(preP.X, preP.Y))); + maxSpikeM = MathF.Max(maxSpikeM, Vector2.Distance(new(m.Position.X, m.Position.Y), new(preM.X, preM.Y))); + } + } + + float sep = Vector2.Distance(new(p.Position.X, p.Position.Y), new(m.Position.X, m.Position.Y)); + _out.WriteLine($"player-vs-monster: sep={sep:F3} m, maxSpike P={maxSpikeP:F3} M={maxSpikeM:F3}"); + Assert.True(sep >= ContactDist - 0.16f, + $"a player converging on a monster must de-overlap to near contact-distance (no PvP exemption vs a creature); got {sep:F3} m"); + Assert.True(maxSpikeP < 0.30f && maxSpikeM < 0.30f, + $"#40 reintroduced for the player mover — a stall-blip escaped the sweep: maxSpike P={maxSpikeP:F3} M={maxSpikeM:F3} m (limit 0.30)"); + } }