From edc911b042957af0e972915bce4f2c01bdaaef97 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 18:07:54 +0200 Subject: [PATCH] refactor(physics): collapse OnPosition's dual player/NPC remote tail into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C4 route 4b-3 collapse (docs/research/2026-08-04-onposition-collapse-contract.md). Behaviour-preserving: the ~640-line duplicated player-guid and NPC-guid copies of the remote routing tail in LiveEntityNetworkUpdateController.OnPosition become one guid-blind tail, reached by every remote guid through the single ApplyRemoteContactRouting/RunRemoteArmTail seam. Two guid-conditionals survive, both named and justified: - Row 8 (TS-44 sticky suppression, creature-only): retail's sticky is independent of this acdream-only steady-state gate; the register row already describes it as NPC-only and this collapse does not widen it. - The AirborneSnap arm's interp-clear + shadow-publish (rows 2a/2b, player-only preserve): unifying either way would be an unauthorized behaviour change. #316 (shadow publish) is a real, unmeasured pre-existing defect, deliberately preserved not fixed. The interp-clear's equivalence could not be proven for the steep-non-walkable-landing edge case (AdjustOffset's CONTACT-keyed gate vs. AP-139's WALKABLE-keyed per-tick clear) — preserved per contract stop condition 2 rather than shipped on an incomplete proof. Category-(c) resolutions (contract §2.1-2.5), each with its evidence: - Row 2a (interp clear): PRESERVED — AdjustOffset's `if (!inContact) return` proves inertness on flat landings, but not on the steep-contact edge case. - Row 2b (shadow publish / #316): PRESERVED — no design note ever sanctioned the player-guid skip; the file's own #184 Slice 2b comments contradict it. - Row 2c (EnsureRemoteMotionBindings): UNIFIED — the method is idempotent (`if (rm.Host is not null) return rm.Sink;`), so "always ensure" is safe. - Row 3 (wire-cell adopt ordering): UNIFIED — RebucketLiveEntity already commits the wire cell before either guid branch runs, so the deleted player-guid pre-write was a proven no-op. - Row 4 (LastServerPos/Time sample timing): UNIFIED — on a genuine first UP, InterpolationManager.Enqueue's already-close branch and the Snapped branch both converge on the same body pose/orientation for a zero-distance target. - Row 12 (wall-clock capture): UNIFIED — one shared `nowSec`, a microsecond-scale skew in acdream-only bookkeeping/diagnostics. Sabotage check (contract §5, performed and reverted, not committed): deleting the one remaining TryArmConstraintAfterOperation call failed 10/16 dual-guid matrix tests, spanning BOTH guid halves of every arming-dependent scenario (teleport, landing, near, far, sticky) — proof the matrix discriminates a defect regardless of which guid range exercises it, closing the class of bug that let 4b-3's A1/A2/R3 findings survive review when only one copy's tests were green. New tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs drives 8 scenarios x 2 guid ranges (0x50xxxxxx player, 0x8xxxxxxx creature) through the complete production OnPosition entry point. Doc comments on ApplyRemoteContactRouting, RunRemoteArmTail, ApplyWireAirborneLeftoverBookkeeping, TryAdoptWireCellAfterRouting, and the AirborneNoOperation throw guard updated to describe the collapsed one-path world (the "two callers stay one decision" claim was true before this commit and false after — fixed in the same commit that makes it false). One branch-routing source-text pin (LiveEntityNetworkBranchRoutingTests.cs) updated to follow the AP-140 CONTACT gate to its new address inside ApplyRemoteContactRouting. #316 stays OPEN, deliberately not fixed here — see its updated ISSUES.md entry. dotnet build AcDream.slnx -c Release: 0 errors. Verified independently bisectable at this exact commit: AcDream.App.Tests 4104/4107 (3 pre-existing skips), AcDream.Runtime.Tests 1125/1125. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 11 + ...2026-08-04-onposition-collapse-contract.md | 561 ++++++++ .../LiveEntityNetworkUpdateController.cs | 1025 ++++++-------- .../LiveEntityNetworkBranchRoutingTests.cs | 26 +- ...ityNetworkOnPositionCollapseMatrixTests.cs | 1243 +++++++++++++++++ 5 files changed, 2225 insertions(+), 641 deletions(-) create mode 100644 docs/research/2026-08-04-onposition-collapse-contract.md create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fa1654a8..9ac8914e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -13133,3 +13133,14 @@ already instruments the packet side of exactly this edge. by contract; adding a publish would be a behaviour change smuggled into a refactor, and its severity is unmeasured. Split-on-discovery: own commit, with a dual-guid test, after the measurement. + +**Post-collapse update (2026-08-04):** the OnPosition collapse dissolved the +standalone player-guid LANDING TRANSITION block into the unified remote +routing tail's `AirborneSnap` arm. The defect this row describes is +UNCHANGED and now lives as an explicit, commented, guid-gated skip at that +arm's shadow-publish step (`arm is RemoteContactArm.AirborneSnap && +IsPlayerGuid(...)` in `LiveEntityNetworkUpdateController.OnPosition`) — +preserved verbatim, not fixed, per this row's own resolution above. Covered +by `LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved` / +`LandingPacket_CreatureGuid_ShadowPublishedQueueNotCleared` in +`tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs`. diff --git a/docs/research/2026-08-04-onposition-collapse-contract.md b/docs/research/2026-08-04-onposition-collapse-contract.md new file mode 100644 index 00000000..bf2d4772 --- /dev/null +++ b/docs/research/2026-08-04-onposition-collapse-contract.md @@ -0,0 +1,561 @@ +# OnPosition remote-tail collapse — pinned contract (2026-08-04) + +**Scope:** collapse the two parallel inline copies of the remote-position +routing tail in +`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.OnPosition` — the +player-guid copy (`IsPlayerGuid(update.Guid)`, `:2264-2679` at HEAD) and the +NPC-guid copy (`:2681-2951`) — into one guid-blind routing path. **This is a +behaviour-preserving refactor.** Pinned at HEAD `b260bcd1`, clean tree, branch +`claude/acdream-physics-divergence-5aa784`. + +**Line numbers in this contract are as-of `b260bcd1` and WILL go stale the +moment the collapse starts. Every citation also names the symbol or the +comment banner; trust the symbol.** (Process rule 6 — line ranges went stale +twice within single review rounds during 4b-2/4b-3.) + +Predecessor documents, all binding where they still apply: +- [`2026-08-04-c4-route-4b-3-contract.md`](2026-08-04-c4-route-4b-3-contract.md) + — its 13 "must REMAIN true" invariants carry forward (§6 below). +- [`2026-08-04-c4-route-4b-3-retail-review.md`](2026-08-04-c4-route-4b-3-retail-review.md) + / [`-round2.md`](2026-08-04-c4-route-4b-3-retail-review-round2.md) and + [`2026-08-04-c4-route-4b-3-architecture-review.md`](2026-08-04-c4-route-4b-3-architecture-review.md) + / [`-round2.md`](2026-08-04-c4-route-4b-3-architecture-review-round2.md) — + round 2's call-site-by-call-site verification of `RunRemoteArmTail` and + `ApplyWireAirborneLeftoverBookkeeping` is the starting map and the method + template for §5. +- [`2026-08-04-session-handoff-c4-remaining.md`](2026-08-04-session-handoff-c4-remaining.md) + — the six process rules apply verbatim. + +## 1. Why this exists + +Retail's `CPhysicsObj::MoveOrTeleport` @0x00516330 has **zero player/NPC +branching** — verified independently in +`docs/research/named-retail/acclient_2013_pseudo_c.txt` by the 4b-3 retail +review (round 1, Part 1) and re-confirmed for this contract: it branches on +the body's own cell (`this_1->cell == 0` @0x00516386), TELEPORT_TS +(`newer_event` @0x00516375), `player_distance` (@0x005163AF/@0x005163C1), and +the wire contact argument (`arg4` @0x0051638E) — never on what KIND of object +it is. The only kind-branch in the whole retail chain is +`SmartBox::HandleReceivedPosition` @0x00453FD0's `arg2 == this->player` +(@0x0045414D) — **local player vs everything else**, never remote-player vs +remote-NPC. acdream's two copies are therefore an architecture artifact, not +a retail port. + +The cost is documented history, not speculation. In route 4b-3's first review +round, THREE of five MAJOR findings were this duplication: **R1** (the D2 +wire-airborne return-0 shape implemented on the player copy only, with a +register row asserting otherwise), **R3/A2** (the player copy's teleport block +returns above its synth-velocity code; the NPC copy had no such boundary, so a +teleported NPC installed a ~1,000 m/s synthesized velocity and sprinted in +place), and **A1** (`ToConstraintArm` written against the player copy's +reality — which provably never produces `AirborneSnap` — and wrong for the NPC +copy, which can, silently dropping the leash to zero arms). An implementer +also sabotaged one copy and watched tests stay green because coverage hit the +other. The route 4b-3 fix round extracted two shared helpers +(`RunRemoteArmTail`, 3 call sites; `ApplyWireAirborneLeftoverBookkeeping`, +2 call sites) as a partial fix. **This slice is the remaining collapse.** + +## 2. Complete behavioural-difference inventory + +Every difference between the two copies at `b260bcd1`, walked top to bottom. +Categories: **(a)** genuine, justified difference that must survive (with the +justification); **(b)** proven-equivalent copy-shape difference — unify, with +the equivalence proof stated per §5; **(c)** unknown — evidence required +before either unifying or preserving. **A difference missed here is a silent +behaviour change; the implementer must re-walk both copies against this table +before writing code and STOP if they find a row this table lacks** (§10 stop +condition 3). + +The shared prologue (observation tracker, `RemoteMotion` get-or-create + +seed + `SeedRemoteSpawnPlacement`, `TryCommitAuthoritativeVelocity`, the +`[remote-slide-up]` probe, `RebucketLiveEntity`, classification, +`TryApplyGenericRemoteRenderPose`) is already shared and is NOT part of this +inventory. `SeedRemoteSpawnPlacement`'s guid-driven mover flags +(`IsPlayer | EdgeSlide` vs `EdgeSlide`) are per-object data retail itself +carries on OBJECTINFO and are already in the correct data-driven shape — out +of scope. + +| # | difference | player copy | NPC copy | category | +|---|---|---|---|---| +| 1 | Teleport dispatch shape | dedicated pre-routing block (`OwnsTeleportPlacement` check → `RunRemoteArmTail` → arm → tail → return, `:2340-2378`) | rides the common routing (D5 inside `ApplyRemoteContactRouting`), with `isTeleportRoute` exclusions sprinkled (D2 conjunct `:2727`, synth gate `:2751`, cycle gate `:2896`, sticky-gate widening `:2804`) | **(b)** — outcome-equivalence verified call-site-by-call-site by the round-2 architecture review Part 1; unify on the in-routing shape (§3) | +| 2 | Landing-family decision site | the LANDING TRANSITION block (`!rmState.Body.InContact` pre-routing, `:2454-2552`), which is what makes `playerArm` provably never `AirborneSnap` (round-2 architecture review, "LANDING TRANSITION block" section) | `ApplyRemoteContactRouting`'s free-flight carve-out (`!remote.Body.InContact` → `AirborneSnap`, `:1177-1204`) plus the shared tail | structural container for 2a-2d; the decision itself (hard-snap, arm `NearInterpolate`) is equivalent — `ToConstraintArm(AirborneSnap) => NearInterpolate` is the A1 fix and equals the landing block's hard-coded arm | +| 2a | Interp queue clear at landing | `rmState.Interp.Clear()` at packet time (`:2456`) | deliberately NOT cleared — the carve-out's own comment ("queue deliberately NOT cleared … clearing stale waypoints is owned by the per-tick LANDING detection", AP-139) forbids restating "already empty" | **(c)** — see §2.1 | +| 2b | Collision-shadow publish at landing | **NOT published** — the landing block returns at `:2551` without `LiveEntityShadowPublisher.TryPublishRemote` (grep: publishes exist at `:2366`, `:2667`, `:2940` only) | published — `AirborneSnap` falls through to the NPC tail's publish (`:2940`) | **(c), suspected pre-existing defect on the player copy** — see §2.2. **New finding: no 4b-3 review flagged this.** | +| 2c | `EnsureRemoteMotionBindings` at landing | called (`:2499-2503`, "the motion bindings still have to exist before the next per-tick commit can dispatch this remote's ground edge") | never called on the OnPosition path (NPC bindings come from UM dispatch / `OnVector`) | **(c)** — see §2.3 | +| 2d | `[remote-landing]` probes (`site: "controller"`) | emitted (`:2514-2550`) | not emitted | **(b)** — diagnostic-only (TEMPORARY family, #32 investigation); carry to the unified landing arm for all guids, or accept NPC lines appearing. Not behaviour. | +| 3 | Wire-cell adopt ordering | `rmState.CellId = p.LandblockId` unconditionally BEFORE routing (`:2279`, AP-135 comment) | adopted AFTER routing via `TryAdoptWireCellAfterRouting` (`:2886`), suppressed for the two placement arms | **(b)** with a proof obligation — see §2.4 | +| 4 | `LastServerPos`/`LastServerPosTime` sample timing | sampled BEFORE routing, inside the diagnostic roll-forward block (`:2306-2310` — the writes are unconditional; only the print is env-gated) | sampled AFTER routing (`:2893-2894`) | **(c)** — see §2.5 (the `firstUp` hint in `ApplyInterpolate` reads this) | +| 5 | `PrevServerPos`/`PrevServerPosTime` roll + `MaxRootMotionSpeedSinceLastUP` reset | player-only (`:2306-2308`), feeds the `[VEL_DIAG]` pace comparison and difference 6 | absent | **(b)** — diagnostic-feeding state; keep as an explicitly-diagnostic step (its only non-diag consumer is difference 6, which is deleted) | +| 6 | Post-routing synth-velocity install | grounded-routing tail only (`:2618-2628`): synth from the `Prev` pair, never `update.Velocity`, no else-clear; comment says "for diagnostics" | pre-routing (`:2751-2770`): `update.Velocity` preferred, else `LastServerPos` delta, else zero/false; gated `!isTeleportRoute` | **(b)** — the player install is **write-only state**: `rm.ServerVelocity`/`HasServerVelocity` production readers are exactly two, both player-excluded (`RemoteServerControlledVelocityCycle.Apply` internal `IsPlayerGuid` return; `RuntimeRemotePhysicsUpdater.cs:198` `!IsPlayerGuid` watchdog — reader enumeration done for this contract, grep `\.HasServerVelocity\|\.ServerVelocity` in `src/`). Delete the player install; the NPC formula becomes the single site. For player guids the unified formula writes values nothing reads — state-different, observably identical; the proof is the reader enumeration, restated in the implementation commit. | +| 7 | Velocity-cycle apply (`RemoteServerControlledVelocityCycle.Apply`) | not called (returns before reaching NPC section) | called (`:2896-2927`), gated `!isTeleportRoute && HasServerVelocity && !snapSuppressedByStick && ae exists` | **(a)** — survives via the helper's own internal `IsPlayerGuid` early return (`RemoteServerControlledVelocityCycle.cs:27-48`, AP-80 row + DEV-2: retail has NO pace-derived cycle refinement anywhere; the NPC half is a recorded acdream adaptation, the player exclusion is the retail-faithful half). The unified path calls it for all remotes; the internal data-driven guard carries the distinction. Provably identical: the guard exists and is first-class, not incidental. | +| 8 | TS-44 sticky suppression (`snapSuppressedByStick`) | absent | gates routing (`:2787-2840`), widened `\|\| isTeleportRoute`; arm site deliberately OUTSIDE the gate | **(a)** — the ONE named difference that survives the collapse. NOT vacuous for players: `LiveEntityMotionRuntimeController.StickToObjectFromWire` (`:342-353`, retail `stick_to_object` 0x005127e0, the mt-0 wire sticky trailer) can arm sticky on ANY remote host, player remotes included. Applying the gate uniformly would be a behaviour change for a stuck remote player (toward retail, whose `adjust_offset`-chain sticky overwrite is guid-blind — but that is TS-44's own recorded divergence to retire on its own evidence, not this slice's). Preserve as an explicit, named, commented gate applied to non-player guids only; the TS-44 register row already describes exactly this ("an NPC-only steady-state gate") and stays true. | +| 9 | Arm-call structure | three arm sites (teleport tail `:2355`, landing block `:2477`, grounded tail `:2606`) | one arm site (`:2854`), outside the sticky gate; `npcArm` initialised `UnroutedCatchUp` so a **sticky-suppressed packet still arms** (comment `:2842-2853`: retail's leash is independent of the acdream-only suppression) | **(b)** — one post-routing arm site in the unified tail; partition (D4 table) already proven identical by round-2 retail review §1. MUST preserve: sticky-suppressed-still-arms (with `UnroutedCatchUp`), guard-before-arm (R5), AP-138(3)'s one-packet unarmed residual on superseded incarnations. | +| 10 | D2 wire-airborne gate conjunct | `!update.IsGrounded` alone (`:2380` — teleport already returned above, conjunct structurally inert) | `!update.IsGrounded && !isTeleportRoute` (`:2727`) | **(b)** — trivially equivalent once dispatch shape (row 1) is unified; the unified gate keeps the explicit conjunct (positive exclusion, the R3-fix principle) | +| 11 | `AirborneNoOperation` return shape | bare `return` (`:2323-2327` — bookkeeping already written by rows 3/4's pre-writes) | writes `CellId`/`LastServerPos`/`LastServerPosTime` then returns (`:2702-2709`) | **(b)** — same observable outcome (AP-135's writes happen exactly once either way); the unified path writes them at the return via the existing helper | +| 12 | Wall-clock capture | multiple independent `DateTime.UtcNow` reads (`:2287`, `:2404`) | one `now` captured in the shared prologue, `nowSec` derived once (`:2681`) | **(b)** — microsecond-scale timestamp skew in acdream-only bookkeeping/diagnostics; unify on one capture (R9 already ruled the duplicate-write concern a clarity issue) | +| 13 | Entity-sync + shadow-publish tail | two inline copies (`:2363-2376` teleport, `:2664-2677` grounded) | third inline copy (`:2937-2950`) | **(b)** — byte-equivalent triplet (`entity.SetPosition(body.Position)`; `ParentCellId = rmState.CellId`; `Rotation = body.Orientation`; `TryPublishRemote(...)`); dedup into one helper/tail. The landing block's PARTIAL tail (sync without publish) is row 2b, not this row. | +| 14 | `RemoteServerControlledVelocityCycle.Apply`'s internal `0x50xxxxxx` early return | (helper-internal, not a branch copy) | (same) | **(a)** — already correctly placed as data-driven logic in the helper; the collapse makes it the ONLY carrier of the player/NPC animation distinction. Do not move, do not "simplify" — its doc block is the DEV-2 evidence chain. | + +**Guid ranges, load-bearing for tests:** `IsPlayerGuid` is +`(guid & 0xFF000000) == 0x50000000` (`:158-159`). The connected-gate evidence +in the 4b-3 contract confirms creatures arrive as `0x8xxxxxxx`. Every +dual-guid test in §5 uses one guid from each range. + +### 2.1 Row 2a — the landing interp-queue clear (c) + +The two copies contradict each other's comments for the same scenario +(wire-grounded packet, body not in contact). The carve-out's comment +explicitly warns a reader who believes the queue is empty could delete +AP-139's per-tick landing clear; the player block clears at packet time with +its own rationale ("pre-arc waypoints are stale"). + +**Equivalence hypothesis to prove or refute:** between the packet and the +next DR tick, a populated queue on a not-in-contact body is inert — retail's +`InterpolationManager::adjust_offset` @0x00555D30 gates its entire body on +`CONTACT_TS` (the AP-140 retirement evidence, comment at `:1154-1176`), and +acdream's per-tick walk honours the same gate — and the tick that derives +contact fires AP-139's clear on the `!previousOnWalkable && finalOnWalkable` +edge **before** any queue walk in that same tick. If both halves hold, the +clear-vs-no-clear difference is unobservable and the unified landing arm +adopts the NO-clear shape (the one with the register row and the do-not-restate +warning). If either half fails — e.g. the per-tick order walks the queue +before the landing clear — the difference is live; STOP, report, and preserve +the player clear as an explicit landing-arm step for BOTH guids only if the +evidence shows the clear is the correct behaviour (that would be a +behaviour change on the NPC arm and needs its own justification + test — +split-on-discovery, process rule 2). + +### 2.2 Row 2b — the landing shadow publish (c, suspected player-copy defect) + +The #184 Slice 2b design comments in this very file (`:2090-2100`, +`:2655-2663`) state the design as "player shadows now follow the RESOLVED +body — via the DR-tick loop … and the player UP-branch tail below … exactly +like NPCs" and "this keeps collision == render for the first UP". The landing +block hard-moves the body and syncs the render entity but does NOT publish +the shadow, leaving collision ≠ render for up to one DR tick — against the +file's own stated design. The NPC copy publishes on the same scenario. + +**Resolution path:** this looks like a pre-existing oversight in the player +copy, not a justified difference. Per process rule 2 (split on discovery), if +investigation confirms it: fix it as its **own commit** (add the publish to +the landing block, with a dual-guid test asserting shadow-follows-body after +a landing packet for both guid ranges) BEFORE the collapse commit, so the +collapse itself stays behaviour-identical and the fix is separately +revertible. If investigation instead finds a deliberate reason the player +landing must not publish (none is written down anywhere — check +`LiveEntityShadowPublisher.TryPublishRemote`'s own gates first), preserve it +as a named difference with a new register row (register rule 1). Do not fold +the change silently into the collapse either way. + +### 2.3 Row 2c — landing bindings (c) + +`EnsureRemoteMotionBindings` at landing exists so the next per-tick commit +can dispatch the ground edge (`HitGround` via `set_on_walkable`, Bug B). For +the NPC copy, bindings normally exist already (every UM dispatch and every +`OnVector` jump ensures them), but "normally" is not "provably": enumerate +whether an NPC can reach the landing scenario with no prior UM/vector (e.g. +spawned airborne, first packet is the landing UP). If yes, the NPC copy has a +latent missing-bindings gap and the unified landing arm ensures bindings for +all guids (a strictly-additive, idempotent call — verify idempotence in +`LiveEntityMotionRuntimeController.EnsureRemoteMotionBindings` before +claiming it). If no, the call is player-load-bearing only and unifying on +"always ensure" is still safe by idempotence — prefer that, stating the +argument. This row should resolve to (b) with a short proof; it is (c) only +until the idempotence + reachability walk is written down. + +### 2.4 Row 3 — wire-cell adopt ordering (b), proof obligation + +The player pre-write and the NPC post-adopt end at the same value on every +path: placement arms overwrite `remote.CellId` with the resolved cell +(`RuntimeSetPositionState.cs:5013`), non-placement arms adopt the wire cell +(player: pre-write; NPC: `TryAdoptWireCellAfterRouting`, which adopts for +`AirborneSnap`/`SteadyStateInterpolate`/`UnroutedCatchUp`), and the D2/no-op +early returns write it through the helper. The residual question is whether +anything READS `remote.CellId` **inside the synchronous routing window** +where the two orderings differ. Traced for this contract: + +- the constraint anchor does NOT — `ArmConstraintAfterOperation` reads + `host.Position`, and the remote host's `getPosition` + (`LiveEntityMotionRuntimeController.cs:167-170`) builds it from + `hostRecord.FullCellId` (committed by the shared prologue's + `RebucketLiveEntity`, or by the placement) — never `rm.CellId`; +- `ApplyInterpolate`, the free-flight carve-out, and + `WillAdvanceRemoteMotion` do not read it; +- the tails read it AFTER the adopt on both copies today. + +The implementer re-verifies this reader enumeration at implementation time +(one grep + walk, stated in the commit), then unifies on the NPC shape +(post-routing, arm-suppressed adopt — it is the one with the documented +suppression rule) with the early-return paths writing via the helper. + +### 2.5 Row 4 — sample timing and `firstUp` (c) + +`ApplyInterpolate`'s AP-87 backstop reads +`firstUp = remote.LastServerPosTime <= 0.0` +(`RuntimeRemoteSteadyStatePosition.cs:138`), and its own doc states the +asymmetry: the player caller stamps before routing, so `firstUp` is +"structurally false there". Unifying on post-routing sampling makes a player +remote's genuine first UP evaluate `firstUp == true` → forced `Snapped` +instead of possibly `Enqueued`. + +**Equivalence hypothesis:** on a genuine first UP the `RemoteMotion` was +created this same packet with `Body.Position = worldPos` (the creation branch +`:2144-2171`), so `bodyToTarget == 0` and `Snapped` vs `Enqueued` differ only +in (i) clearing an already-empty queue and (ii) `Enqueue`'s possible +immediate-orientation return vs the snap's direct orientation write — both +ending at the same orientation for a zero-distance target. Also enumerate the +non-creation `firstUp` producers (a UM's locomotion-entry refresh stamps +`LastServerPosTime` in `OnMotion`, making `firstUp` false before the first UP +— both copies inherit that identically). If the hypothesis survives the walk, +unify on ONE post-routing sample (row 4 → (b)) and keep the player diagnostic +roll-forward reading the OLD value before the sample point. If it does not, +preserve the pre-routing stamp as an explicit named step and record why. Do +not hand-wave the `Enqueue` immediate-orientation subtlety — read +`InterpolationManager.Enqueue` before claiming equivalence. + +## 3. Target shape + +One remote routing tail, zero `IsPlayerGuid` branching in `OnPosition`'s +remote section except the single named survivor: + +``` +shared prologue (unchanged) // tracker, get-or-create+seed, + // velocity install, rebucket, + // classification, generic pose +if IsAirborneNoOperation(route): + ApplyWireAirborneLeftoverBookkeeping(...) // row 11 unified shape + return +isTeleportRoute = OwnsTeleportPlacement(route) +if !update.IsGrounded && !isTeleportRoute: // D2, one site (row 10) + ApplyWireAirborneLeftoverBookkeeping(...) + return +[NPC-only, row 8 — THE named survivor] + snapSuppressedByStick = ... (non-player guids only, unchanged predicate) +if !snapSuppressedByStick || isTeleportRoute: + routing = RunRemoteArmTail(...) // teleport decided INSIDE + if routing is null: return // routing (row 1); landing + arm = routing.Arm // family = AirborneSnap arm + // (row 2), incl. its resolved + // 2a/2b/2c steps +else: arm = UnroutedCatchUp // sticky-suppressed still arms +TryArmConstraintAfterOperation(ToConstraintArm(arm), rmState) // ONE site (row 9) +TryAdoptWireCellAfterRouting(rmState, arm, p.LandblockId) // ONE site (row 3) +one post-routing LastServerPos/Time sample // row 4 resolution +if !isTeleportRoute: synth-velocity install // row 6: NPC formula, all guids +if !isTeleportRoute && HasServerVelocity && !snapSuppressedByStick && ae: + RemoteServerControlledVelocityCycle.Apply(...) // row 7: internal guid guard +one entity-sync + shadow-publish tail // row 13 +``` + +The teleport arm needs no dedicated pre-block: `ApplyRemoteContactRouting` +already dispatches it first (D5), and the post-routing steps are all either +teleport-suppressed by existing predicates (`TryAdoptWireCellAfterRouting`, +the two `!isTeleportRoute` gates) or teleport-correct (the arm partition, the +tail sync from the resolved body — invariant 2). The landing family +dissolves into the `AirborneSnap` arm: `ToConstraintArm(AirborneSnap) => +NearInterpolate` (the A1 fix) already produces the landing block's exact arm +value, and rows 2a-2d state how each remaining landing-block extra resolves. + +**Surviving differences: exactly one branch (row 8, TS-44 sticky gate, +non-player guids), plus two data-driven distinctions that live inside helpers +and involve no branching in `OnPosition`** — row 7/14's +`RemoteServerControlledVelocityCycle.Apply` internal player return (AP-80 / +DEV-2) and the shared prologue's `SeedRemoteSpawnPlacement` mover flags. +Anything else surviving means a (c) row resolved to "preserve" — each such +outcome must be reported, not silently kept. + +Whether the unified tail lives as a private method on the controller or +inline in `OnPosition` is the implementer's choice; what is pinned is ONE +copy, the step order above, and that `ApplyRemoteContactRouting`, +`RunRemoteArmTail`, `ToConstraintArm`, `TryArmConstraintAfterOperation`, +`TryAdoptWireCellAfterRouting`, and `ApplyWireAirborneLeftoverBookkeeping` +keep their current semantics unchanged (their internals are NOT in scope — +they were verified twice in round 2). + +## 4. Behaviour-preservation strategy + +This is a refactor: **every row of §2 is either preserved with its stated +justification or unified with its stated proof — never silently merged.** +Concretely: + +1. **Characterisation first.** Before touching production code, write the + dual-guid matrix tests of §5 against the CURRENT code and confirm they + pass, encoding today's behaviour — including the known asymmetries (rows + 2b, 8) asserted AS asymmetries where they exist today. These tests are the + refactor's referee. +2. **Per-row disposition in the commit message.** The implementation commit + carries a conformance section listing every §2 row and its outcome: + `(a) preserved — `, `(b) unified — `, or + `(c) resolved to (a)/(b) — `. The round-2 architecture review's + Part 1 (call-site table: pre-fix sequence, post-fix sequence, verdict, + what-was-checked-for-and-not-found) is the format to follow. +3. **(c) rows resolve before merge.** A (c) row that cannot be resolved to + proven-equivalent without changing behaviour is PRESERVED as an explicit + named difference and reported — this slice never ships a behaviour change + (§7). A (c) row that turns out to be a pre-existing defect (2b is the + prime suspect) is fixed in its own commit per process rule 2, with its own + test, before the collapse commit. +4. **No helper-internal edits.** If the collapse seems to require changing + `ApplyRemoteContactRouting` / `RuntimeRemoteSteadyStatePosition` / + `RuntimeRemoteFarSnapPosition` / classifier internals, stop — that is a + scope breach, not a refactor step. +5. **Comment hygiene** (process rule 6): every comment inside the collapsed + region is re-verified against the code beside it; the comments that + NARRATE the duplication ("the SAME shared entry point the NPC arm below + calls", "mirror of the player-remote arm above", `RunRemoteArmTail`'s + three-copies rationale, `ApplyWireAirborneLeftoverBookkeeping`'s + "shared by both remote branches" paragraph, `ApplyRemoteContactRouting`'s + "the two callers stay one decision") are rewritten for the one-path world. + Symbol references over line numbers, always. + +## 5. Test strategy — the one that would have caught the 4b-3 defects + +The 4b-3 defects survived because every test drove ONE copy. The antidote is +structural: **every scenario test in this slice is a `[Theory]` parameterised +on guid — one `0x50xxxxxx` player-range guid, one `0x8xxxxxxx` +creature-range guid — running the identical packet sequence through +`OnPosition` and asserting on the full observable surface.** After the +collapse there is one path, so this is cheap; the point is it STAYS a theory +so a future re-divergence (a guid-gated edit to the unified tail) fails a +test instead of hiding. + +Scenario matrix (each × both guids): + +1. **Teleport commit** (TELEPORT_TS advance, in-view destination): body and + entity at resolved destination, hook ran (moveto cancelled, stick + released, interp queue empty), leash armed exactly once post-operation, + shadow published, **sequencer cycle unchanged and + `HasServerVelocity == false`** (the R3/A2 assertion — for BOTH guids; + pre-collapse this was true for players by control flow and for NPCs by + the `!isTeleportRoute` gate; post-collapse one mechanism must serve both). +2. **Landing packet** (wire grounded, body not in contact): body snapped to + wire pose, entity synced, armed exactly once (the A1 assertion — + `PositionManager.Constraint` null → non-null, the round-2-verified + direct-proof observable), plus the resolved 2a/2b/2c outcomes (queue + state, shadow state, bindings state) as pinned by their (c) resolutions. +3. **Wire-airborne, null-classified** (login-window shape): exactly AP-135's + bookkeeping (`CellId`, `LastServerPos`, `LastServerPosTime` — assert the + writes HAPPENED, the round-2 B1 gap, not only that nothing else did), + no body/queue/render/shadow write, no arm (the R1 assertion). +4. **Airborne no-op** (`NoPositionOperation`): AP-135 writes only, no arm. +5. **Near interpolate** (grounded, < 96 m, body in contact): enqueued (or + AP-87-snapped per its three conditions), armed exactly once. +6. **Far snap** (grounded, >= 96 m): placement executed, armed on every + placement outcome, wire-cell adopt suppressed. +7. **Sticky-suppressed steady-state** (NPC guid: sticky armed, near packet): + no snap/enqueue, but STILL armed once with `UnroutedCatchUp`. Player-guid + half of this theory asserts the CURRENT player behaviour (gate absent → + routing runs) — this is row 8's asymmetry, asserted explicitly as the + named difference so it is recorded in test, not hidden. +8. **NPC velocity-cycle** (grounded packet with synthesizable velocity): + creature guid gets a planned cycle; player guid's sequencer is untouched + (row 7/14's data-driven distinction, asserted as the intended difference). + +Assertion surface per scenario (assert ALL, not a subset — process rule 4, +#312's lesson): `rmState.Body.Position/Orientation`, `entity.Position`/ +`ParentCellId`/`Rotation`, shadow entries (`AllEntriesForDebug`), +`Interp` queue depth, `PositionManager.Constraint` (arm count via +null→non-null, or a counting seam if a packet can legally arm twice — it +cannot: D4), `rmState.CellId`, `LastServerPos`/`LastServerPosTime`, +`ServerVelocity`/`HasServerVelocity`, sequencer style/motion. + +**Sabotage check (manual, once, before the collapse commit is finalised):** +re-run the 4b-3 experiment — introduce a deliberate defect into the unified +tail (e.g. skip the arm call) and confirm BOTH guid halves of the matrix +fail. If only one fails, the matrix has a per-guid hole; fix the test, not +the sabotage. + +Existing tests: the three round-2 App tests +(`NpcAirborneSnap_LandingPacket_StillArmsTheLeash`, +`NpcTeleport_DoesNotInstallASynthesizedVelocity`, +`NullClassifiedNpc_WireAirbornePacket_WritesOnlyBookkeepingNoBodyOrShadow`) +are absorbed into the matrix as the creature-guid halves of scenarios 2/1/3 — +extended, not deleted. `LiveEntityNetworkRemoteTeleportPresentationTests` +and the Runtime teleport suites are untouched. + +## 6. What must remain true + +The 4b-3 contract's invariants bind unchanged; restated here as they apply to +this slice, plus the collapse-specific ones: + +1. **The pose still advances on every refusal** — teleport/far placements + that never reach the engine still commit the accepted destination through + the `StoresAcceptedDestination` partition; `Deferred`/`RejectedByPlacement` + still do not. (Runtime-side; this slice must simply not perturb the call.) +2. **Presentation still syncs** — every arm that moves the body syncs the + render entity from the RESOLVED body and publishes the shadow (subject to + row 2b's resolution); a remote is never left rendered a packet behind its + body (#312's layer). +3. **The entity stays in-world** on every outcome: `InWorld`, clock active, + `FullCellId != 0`, spatial projection intact. +4. **The leash arms exactly once per accepted packet per the D4 partition** — + never zero (A1's class), never twice; sticky-suppressed still arms; + guard-before-arm on the re-entrant arms (R5); AP-138(3)'s superseded- + incarnation residual unchanged. +5. **AP-135's two writes survive on every early-return path** (the + register row stays). +6. **The per-packet prologue runs for every classification**: generic render + pose (gate `OwnsSteadyState`, unchanged), `RebucketLiveEntity`, the + `TryCommitAuthoritativeVelocity` install, currency re-validation chain. +7. **Teleport semantics unchanged**: hook before placement, hook regardless + of outcome, queue cleared by the hook not the route flag, no velocity + write on the teleport arm, sticky does not suppress it, decided ahead of + every contact carve-out (D5). +8. **AP-87's snap conditions, AP-139's landing clear, and AP-140's + contact-not-walkability routing are untouched** (their owners are outside + this slice's edit surface; rows 2a/4 touch only their CALLERS' timing and + must prove equivalence first). +9. **`ApplyRemoteContactRouting`'s `AirborneNoOperation` throw stays + unreachable** — both early returns (now one) still precede routing. +10. **The local-player paths are untouched**: force-position block, F751 + `OfferDestination` tail, streaming observer, projectile short-circuit — + everything above the remote section and the `update.Guid == + _playerServerGuid` filter semantics. + +## 7. Explicit non-goals + +- **No behaviour change.** Any (c) row resolving to "the difference is real + and one side is wrong" becomes its own separately-committed, separately- + tested fix (2b's suspected missing publish) or is preserved and reported — + the collapse commit itself is behaviour-identical by the §5 matrix. +- **No register-row retirement.** AP-135, AP-137, AP-138, AP-87, TS-44, the + AP-80 adaptation — all stay. Row TEXT may be touched only to repoint + citations at moved/renamed symbols (bookkeeping, same commit), never to + change what a row claims. +- **No absorption of C4 route 5 (projectile) or route 7 (pickup/parent).** + Route 5 will add its arm against the collapsed single path — that is the + payoff, not the scope. `OwnsPlacement` keeps excluding + `ProjectileAuthoritative`. +- **No edits inside** `ApplyRemoteContactRouting`, the classifier, + `RuntimeRemoteSteadyStatePosition`, `RuntimeRemoteFarSnapPosition`, + `RuntimeRemotePlacementDriveController`, `RemoteTeleportHook`, or + `RemoteServerControlledVelocityCycle` — except comment repointing. +- **No probe deletions.** The TEMPORARY families (`REMOTE_LANDING`, + `REMOTE_SLIDE`, `REMOTE_TELEPORT`, sticky) move with their code; stripping + them is the physics-settling cleanup, not this slice. +- **No OnMotion/OnVector/OnState changes** beyond comment repointing if a + cited symbol moves. + +## 8. #315 — recommendation: fix it in this slice, as its own commit + +#315 records the per-packet `Func` closure allocation at the three +`RunRemoteArmTail` call sites; its root cause is +`ApplyRemoteContactRouting`'s `Func` parameter, and its acceptance +criterion is "the call sites do not allocate a fresh delegate per packet." + +**Fix it here.** The collapse converges the three call sites into one and +redesigns exactly the seam (`RunRemoteArmTail`'s signature and its +`isCurrentPositionOwner` plumbing) the fix must touch; a standalone #315 +session afterwards would rewrite the same lines a second time, and route 5 is +about to add its arm against whichever shape exists. Both round-2 reviews +deferred it only because it was out of 4b-3's scope — "file it with the probe +family rather than churning the seam now"; this slice IS the seam churn. + +**But as the SECOND commit of the slice, not fused into the collapse +commit.** Commit 1: the collapse, behaviour-identical, keeping the current +closure shape so the collapse diff is purely structural and the §5 matrix +referees it alone. Commit 2: the allocation fix on the now-single call site +(cached per-controller delegate, a small readonly state struct with a static +lambda, or an interface-shaped callback — implementer's choice; the +`Func` parameter is `internal`, so test call sites update mechanically), +closing #315 with its ISSUES.md move in the same commit. Two commits keep the +behaviour-preservation review and the allocation review independently +revertible; one session avoids the double churn. This is a recommendation +with a reason, not a hedge: the only argument against ("two concerns") is +answered by the commit split. + +## 9. Gates + +- **Focused:** the §5 dual-guid matrix (new), plus the existing Runtime + teleport/steady-state suites and App physics suites, all green. +- **Complete Release suite:** + `$env:ACDREAM_PAK_PATH = "$env:USERPROFILE\Documents\Asheron's Call\acdream.pak"`, + `dotnet test AcDream.slnx -c Release -m:1`. **Baseline 11,020 passed / + 4 skipped / 0 failed at `b260bcd1`.** The count will rise with the new + matrix; measure and record the new figure. Two known flakes, never chase + and never conflate (they have been conflated twice): **#302** + (`PortalProjectionTests.ClipToRegion_FrameOwnedStore_…`, GC-allocation + assertion, App.Tests) and **#308** (`NakEmissionTests.LossSoak_…`, + wall-clock deadline, Core.Net.Tests, full-suite load only). If either + appears, re-run and say which. +- **Connected evidence — argued, not assumed.** If the slice lands as + contracted — zero behaviour deltas; every §2 row (a)-preserved or + (b)-proven — then no new connected gate is REQUIRED: the behaviours routed + through this code passed their live gates days ago on this exact branch + (4b-2's far-snap walk; 4b-3's 16-probe-line creature-teleport session, + user-accepted "all works"), and a proven-identical refactor cannot alter + what those sessions verified. The thing the previous live gates could not + see — a defect hiding in the un-exercised copy — is precisely what the §5 + dual-guid matrix now covers deterministically, which is stronger evidence + than another look-around session (process rule 5: a clean-looking session + proves little). **Two exceptions re-arm the connected requirement:** + (i) any behaviour-affecting (c) resolution shipped as its own commit (2b's + publish fix would warrant the landing-observation half of the handoff's + far-snap recipe: observer watches a second character jump/run off a ledge + and land, watching for any snap-back or collision oddity at landing); + (ii) any unplanned behaviour delta discovered late (stop condition anyway). + If the user happens to be running a session, an opportunistic creature + `@teleto` + landing walk with `ACDREAM_PROBE_REMOTE_TELEPORT=1` is cheap + insurance — optional, not a gate; record probe lines if run. + +## 10. Budget and stop conditions + +**Budget:** ~250-450 changed non-comment production lines, **net negative in +`LiveEntityNetworkUpdateController.cs`** (the remote section is ~690 lines at +`b260bcd1`; the collapse should remove roughly a copy's worth minus the +unified tail). New test code: the §5 matrix, unbounded by this figure but +expected ~400-700 lines. Commit 2 (#315): ~40-100 lines. + +**Stop and report rather than pushing through when:** + +1. Production-line delta exceeds ~500, or the unified path needs a THIRD + guid-conditional beyond row 8's named survivor. +2. Any (c) row resolves to "cannot prove equivalence AND cannot preserve + without contorting the unified path" — report the row and the evidence; + the fallback of keeping both copies for that one step with a tracking + issue is a legitimate outcome, silent unification is not. +3. **A behavioural difference is found that §2 does not list.** Add it to + the inventory, classify it, and get it reviewed before proceeding — a + missed difference is this contract's failure mode, and discovering one + mid-implementation means the inventory walk must be redone, not patched. +4. The §5 characterisation matrix FAILS against the current `b260bcd1` code + in a way this contract does not predict — that is a pre-existing defect + or a wrong row here; split it out (process rule 2) or correct the + contract first. +5. The complete Release suite deviates from baseline beyond the two named + flakes. + +## 11. Contradictions found while writing this contract — reported, not smoothed + +1. **The reviews' "the two callers stay one decision" framing understates + the residual duplication.** Round 2 verified the five extracted-helper + call sites are behaviour-identical — true — but no review inventoried the + copies' remaining differences as a set. Specifically, **row 2b (the + player landing block performs the entity sync but NOT the shadow publish, + while the NPC copy's identical scenario publishes) appears in no review, + no register row, and contradicts the file's own #184 Slice 2b design + comments** ("player shadows now follow the resolved body … exactly like + NPCs", "keeps collision == render for the first UP"). It is at most a + one-DR-tick collision/render divergence, but it is exactly the class + (#184/#312 — presentation/collision desync) this campaign treats as real. +2. **`ApplyRemoteContactRouting`'s doc** ("the player-remote caller reaches + this method only with `Body.InContact == true` … so the carve-out is + inert there and the two callers stay one decision") is accurate today but + will be FALSE after the collapse (the landing family becomes the + `AirborneSnap` arm for player guids too). It is on §4.5's rewrite list; + flagged here because it is the one comment whose staleness would actively + misdirect the implementer. +3. **The 4b-3 contract's baseline figure (11,027 at `2eb39a02`) vs this + task's (11,020 / 4 at `b260bcd1`)** — not a true contradiction (different + HEADs; route 6 and #314 landed between), but stated so nobody "corrects" + one to the other. +4. **AP-137's "unifies player and NPC remotes on one behaviour"** is true + exactly as scoped (the D2 wire-airborne leftover shape) and must not be + read as claiming the copies are otherwise unified — rows 2-6, 8, 12-13 + remain distinct at `b260bcd1`. The row needs no edit; the caution is for + readers of it. +5. **`ApplyInterpolate`'s doc claims its `firstUp` evaluation "is exact for + BOTH kinds"** because the player caller pre-stamps. That is a description + of the asymmetry, not equivalence — the collapse (row 4) is where the + claim gets cashed out or the stamp order preserved; the doc will need the + matching rewrite either way. diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 5581a28c..d27c7bbd 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -1050,7 +1050,8 @@ internal sealed class LiveEntityNetworkUpdateController /// /// The complete remote grounded/contact routing for ONE accepted Position, - /// shared by the player-remote and NPC-remote arms — retail's + /// shared by every remote guid through the single collapsed + /// OnPosition tail (C4 route 4b-3 collapse, 2026-08-04) — retail's /// CPhysicsObj::MoveOrTeleport (0x00516330) makes no /// this == player distinction on any of these branches. /// @@ -1060,10 +1061,18 @@ internal sealed class LiveEntityNetworkUpdateController /// decided BEFORE the near-Interpolate branch can claim it. A landing /// packet classifies Interpolate, so if the 4a test came first it /// would ENQUEUE a body that must PLANT, and a creature knocked off a - /// ledge would glide down over a packet interval. The player-remote caller - /// reaches this method only with Body.InContact == true (its landing - /// block sits ahead of its routing and returns), so the carve-out is inert - /// there and the two callers stay one decision. + /// ledge would glide down over a packet interval. Before the collapse the + /// player-remote caller had its own pre-check (the standalone LANDING + /// TRANSITION block) that reached this method only with + /// Body.InContact == true, making the carve-out inert for that + /// caller specifically. That pre-check is deleted: the single caller now + /// reaches this method with either contact state for either guid, so the + /// carve-out is live for both — its AirborneSnap result is exactly + /// the dissolved landing scenario, for every guid (see + /// ToConstraintArm's A1 mapping and OnPosition's own + /// arm is AirborneSnap handling for the two guid-preserved + /// extras — #316's shadow-publish skip and the interp-clear — that ride + /// along with it). /// /// /// @@ -1228,15 +1237,14 @@ internal sealed class LiveEntityNetworkUpdateController // where the comment ASSERTED unreachability that no code // enforced. Retail's arg4 == 0 branch writes NOTHING at all // (@0x0051636D returns 0), so there is no operation this - // method could perform; both production callers early-return - // on IsAirborneNoOperation before they route (the player arm's - // AIRBORNE NO-OP block, the NPC arm's mirror of it — the two - // `IsAirborneNoOperation` call sites in this file, cited here - // by name because line numbers went stale within one review - // round). Reaching here means a caller - // skipped that gate, and the only faithful answer is to say - // so — ApplyInterpolate's own doc likewise forbids being - // called for this disposition. + // method could perform; the single production caller + // (`OnPosition`'s collapsed unified tail, since the 2026-08-04 + // OnPosition collapse — cited by name because line numbers + // went stale within one review round) early-returns on + // `IsAirborneNoOperation` before it ever routes. Reaching here + // means a caller skipped that gate, and the only faithful + // answer is to say so — ApplyInterpolate's own doc likewise + // forbids being called for this disposition. throw new InvalidOperationException( "A NoPositionOperation (airborne no-op) classification " + "must be handled by the caller's own early return " @@ -1277,15 +1285,21 @@ internal sealed class LiveEntityNetworkUpdateController /// /// /// Fix round (2026-08-04, R1/A7): shared by both remote branches so they - /// cannot re-diverge on this shape the way they did before this round — - /// the player arm had it, the NPC arm did not, so a wire-airborne + /// could not re-diverge on this shape the way they had before that + /// round — the player arm had it, the NPC arm did not, so a wire-airborne /// null/Rejected* NPC packet fell through to /// 's own free-flight carve-out /// and received a body write, an arm, and a render/shadow publish - /// retail's return 0 never produces. The player arm's own call - /// site sets redundantly (it already wrote - /// the identical value unconditionally before reaching this point); the - /// NPC arm's call site is where the write is actually load-bearing. + /// retail's return 0 never produces. The OnPosition collapse + /// (2026-08-04) went further: there is now exactly ONE call site per + /// early-return (the IsAirborneNoOperation return and the D2 + /// wire-airborne return), reached by every guid, so is load-bearing at both — no caller writes it + /// redundantly beforehand any more (the former player-guid pre-routing + /// write this paragraph used to describe is deleted; proven a true + /// no-op by the round-2 architecture review's call-site check, since + /// RebucketLiveEntity commits the identical value earlier in the + /// SAME packet's processing regardless of guid). /// /// private static void ApplyWireAirborneLeftoverBookkeeping( @@ -1314,16 +1328,20 @@ internal sealed class LiveEntityNetworkUpdateController /// /// /// Extracted after two independent reviews found the three hand-written - /// App-layer copies of this sequence (the player arm's teleport - /// dispatch, the player arm's grounded-routing dispatch, the NPC arm's - /// single dispatch) had begun to drift in ways this class's own - /// duplication made invisible: A2/R3 is precisely the player copy's - /// teleport block returning before its synth-velocity code while the - /// NPC copy had no such boundary at all, and A1 is + /// App-layer copies of this sequence that existed at the time (the + /// player arm's teleport dispatch, the player arm's grounded-routing + /// dispatch, the NPC arm's single dispatch) had begun to drift in ways + /// this class's own duplication made invisible: A2/R3 is precisely the + /// player copy's teleport block returning before its synth-velocity code + /// while the NPC copy had no such boundary at all, and A1 is /// ToConstraintArm having been written against only the player - /// copy's reachable arm set. One implementation now backs every call - /// site so a future edit cannot silently narrow or widen one copy's - /// guard relative to the others'. + /// copy's reachable arm set. The OnPosition collapse (2026-08-04) went + /// further and removed the guid-conditional dispatch shape itself: this + /// method now has exactly ONE call site in production, in the unified + /// remote routing tail, reached by every guid — so the "narrow or widen + /// one copy's guard relative to the others'" failure mode this + /// extraction was built to prevent can no longer arise structurally, + /// not merely by convention. /// /// /// @@ -1332,11 +1350,11 @@ internal sealed class LiveEntityNetworkUpdateController /// caller must return without arming, adopting the wire cell, or /// publishing the render entity/collision shadow. Does NOT itself arm /// the leash ('s corrected mapping is - /// applied by the caller, which also needs it for the NPC arm's - /// sticky-suppressed default-arm case where this method is never - /// called) and does NOT itself gate the synth-velocity install (NPC- - /// only code with no counterpart here — see the NPC arm's own - /// isTeleportRoute guard). + /// applied by the caller, which also needs it for the sticky-suppressed + /// default-arm case where this method is never called) and does NOT + /// itself gate the synth-velocity install (the caller's own + /// isTeleportRoute guard, unconditional for every guid since the + /// collapse). /// /// private RemoteContactRouting? RunRemoteArmTail( @@ -1375,9 +1393,14 @@ internal sealed class LiveEntityNetworkUpdateController } /// - /// C4 route 4b-2: the NPC-remote arm's post-routing wire-cell adoption, - /// extracted so its ONE suppression rule is exercised by production and by - /// test through the same entry point rather than restated in a test body. + /// C4 route 4b-2: the post-routing wire-cell adoption, extracted so its + /// ONE suppression rule is exercised by production and by test through + /// the same entry point rather than restated in a test body. Since the + /// OnPosition collapse (2026-08-04) this is the ONLY wire-cell adopt site + /// for every guid — the former player-guid pre-routing write (which set + /// the identical value unconditionally, before classification, and was + /// proven a true no-op by the round-2 architecture review's call-site + /// check) is deleted. /// /// /// writes THROUGH to the canonical @@ -1387,8 +1410,8 @@ internal sealed class LiveEntityNetworkUpdateController /// CPhysicsObj::SetPositionInternal (0x00515BD0) resolves the /// destination cell through AdjustPosition/set_cell and /// nothing writes the wire cell over it afterwards — so this write is - /// suppressed for both arms. Unlike the player arm, whose identical - /// write sits BEFORE its routing, the NPC one sits after; leaving it + /// suppressed for both arms. This call sits AFTER routing (row 3 of the + /// collapse contract, resolved to unify on this shape); leaving it /// unguarded would discard a resolved cell that differs from the wire /// cell. Every other arm performs no placement, so the wire cell is still /// the newest truth there. @@ -2253,477 +2276,101 @@ internal sealed class LiveEntityNetworkUpdateController interpFailCount: slideFailCount); } - // L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for - // player remotes. Mirrors CPhysicsObj::MoveOrTeleport - // (acclient @ 0x00516330) — airborne no-op, far-snap, near - // InterpolateTo. Gated on IsPlayerGuid so NPCs continue through - // the legacy synth-velocity branch below; their motion comes - // from ServerVelocity / ServerMoveTo which the legacy path - // already handles correctly. - // - if (IsPlayerGuid(update.Guid)) - { - // InterpolationManager retains the complete target Position. - // A near correction replaces both origin and orientation via - // Position::subtract2; only placement/far branches snap here. - // Adopt server's cell ID on every UP (airborne or grounded). - // Required by the legacy airborne path's per-tick - // ResolveWithTransition gate (rm.CellId != 0); without this - // an airborne player remote falls through the floor because - // the sphere sweep is skipped. Note: enabling the sweep also - // exposes a pre-existing depenetration bug — see #42. This is - // acdream's OWN free-fall sweep bookkeeping, not a retail - // CPhysicsObj field, so the D1 "writes nothing" rule below - // deliberately does not reach it — register row AP-135, and - // the NPC arm keeps the identical pair for the same reason. - rmState.CellId = p.LandblockId; - - // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous - // server-pos snapshot forward AND print the per-UP comparison - // between the max literal CSequence root-motion speed observed - // since the last UP and the actual server broadcast pace. Both are sampled - // over the same window so the ratio reflects real overshoot. - { - double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && rmState.LastServerPosTime > 0.0) - { - double dtServer = nowSecDiag - rmState.LastServerPosTime; - if (dtServer > 0.001) - { - var serverDelta = worldPos - rmState.LastServerPos; - float serverSpeed = (float)(serverDelta.Length() / dtServer); - float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP; - if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f) - { - System.Console.WriteLine( - $"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s " - + $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s " - + $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}"); - } - } - } - rmState.MaxRootMotionSpeedSinceLastUP = 0f; - rmState.PrevServerPos = rmState.LastServerPos; - rmState.PrevServerPosTime = rmState.LastServerPosTime; - rmState.LastServerPos = worldPos; - rmState.LastServerPosTime = nowSecDiag; - } - - // ── AIRBORNE NO-OP (C4 route 4a / D1+D2) ───────────────────── - // Retail CPhysicsObj::MoveOrTeleport (0x00516330): arg4 == 0 - // (the wire has_contact bit) falls straight to `return 0` - // @0x0051636D and writes NOTHING — not the body, not the - // interpolation queue, not the render entity, and (because - // ConstrainTo sits inside `if (MoveOrTeleport(...) != 0)` at - // @0x00454254) not the ConstraintManager leash either. The - // classifier read the SAME wire bit this packet carries, so - // there is nothing left to undo: the generic render-pose - // write above was suppressed for this exact classification. - if (RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation( - earlyRemoteRoute)) - { - return; - } - - // C4 route 4b-3 (D5): the teleport/cell-less classification - // is routed AHEAD of every remaining contact carve-out — - // retail decides @0x00516386 before ever reading arg4 - // (the wire contact bit, @0x0051638E). A teleport-classified - // packet must reach the teleport arm regardless of wire or - // body contact, so this check precedes both the legacy - // wire-airborne fallback below and the LANDING TRANSITION - // block. ApplyRemoteContactRouting runs teleport_hook then - // the canonical placement; the currency re-check afterward - // is the SAME rule the far arm's tail already follows (both - // arms are re-entrant — AP-138). - if (RuntimeRemoteTeleportPosition.OwnsTeleportPlacement( - earlyRemoteRoute)) - { - RemoteContactRouting? teleportRouting = RunRemoteArmTail( - acceptedPositionCanonical, - positionRecord, - rmState, - earlyRemoteRoute, - update.Guid, - worldPos, - rot, - () => IsCurrentPositionOwner(entity)); - if (teleportRouting is null) - return; - - RuntimeRemoteSteadyStatePosition - .TryArmConstraintAfterOperation( - ToConstraintArm(teleportRouting.Value.Arm), - rmState); - - // Invariant 2: the render entity advances from the - // RESOLVED body, and the collision shadow publishes, - // exactly as both grounded arm tails do. - entity.SetPosition(rmState.Body.Position); - entity.ParentCellId = rmState.CellId; - entity.Rotation = rmState.Body.Orientation; - AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( - _liveEntities, - positionRecord, - entity, - rmState, - acceptedPositionAuthorityVersion, - () => _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, - rmState, - _origin.CenterX, - _origin.CenterY)); - return; - } - - if (!update.IsGrounded) - { - // C4 route 4b-3 (D2): the retail return-0 shape, applied - // to the two acdream-only leftover classifications (null - // during the login window; RejectedAuthority/RejectedData) - // that remain wire-airborne here — teleport/cell-less - // moved onto the arm above. Shared with the NPC arm's - // identical call (fix round 2026-08-04, R1/A7) so the two - // cannot re-diverge on this shape. This deletes the - // legacy entity-revert quirk - // (entity.SetPosition(rmState.Body.Position)): TickAnimations - // will re-project the mid-arc body next frame regardless, - // and the render entity already holds this packet's wire - // pose from the generic write near the top of OnPosition - // (TryApplyGenericRemoteRenderPose — these classifications - // are not OwnsSteadyState, so that write ran unconditionally - // for this packet); nothing here needs to correct it. The - // cell-adopt this method also performs is redundant here - // (this arm already wrote the identical value - // unconditionally above) but harmless. - ApplyWireAirborneLeftoverBookkeeping( - rmState, - p.LandblockId, - worldPos, - (System.DateTime.UtcNow - System.DateTime.UnixEpoch) - .TotalSeconds); - return; - } - - // ── LANDING TRANSITION ──────────────────────────────────────── - // First IsGrounded=true UP while the body is still in FREE - // FLIGHT — not in contact with any surface (`!Body.InContact`, - // derived by the per-tick SetPositionInternal commit from the - // sweep's contact plane). - // Hard-snap to the authoritative landing position and clear the - // interpolation queue (a free-flying remote's Positions - // hard-snap and never enqueue, so any pre-arc waypoints are - // stale). - // `rmState.Airborne` is deliberately NOT cleared here: the next - // tick derives it from the sweep, which is the only thing that - // can tell walkable ground from a steep face. - // - // AP-140 (retired 2026-08-04): this gate read `rmState.Airborne` - // (`!Body.OnWalkable` — WALKABILITY). Retail's predicate for - // exactly this snap-vs-interpolate decision is CONTACT: - // `InterpolationManager::adjust_offset` @0x00555D30 gates its - // entire body on `transient_state & 1` @0x00555D52, and bit 0 is - // `CONTACT_TS` (acclient.h:3690), not `ON_WALKABLE_TS` (0x2). A - // remote in contact with a NON-walkable face — sliding down a - // steep roof, ordinary since Bug B `204d0ae0` deleted the - // per-tick walkability forge — was taking this hard snap at - // UpdatePosition cadence; it now falls through to the grounded - // routing below and interpolates, as retail does. See the twin - // gate in ApplyRemoteContactRouting for why `Airborne` itself is - // left alone. - // - // Bug B (2026-08-04) — the twin of the per-tick forge. This - // block used to additionally zero the body velocity, assert - // `Contact | OnWalkable`, invoke MovementManager::HitGround, and - // clear the Gravity STATE bit. All four are deleted: - // • the velocity zero discarded the authoritative vector ACE - // delivered (retail MoveOrTeleport 0x00516330 never reads or - // writes the wire velocity for a remote at all); - // • the transient assert forged the two facts retail derives - // from the contact plane in SetPositionInternal - // (0x00515430 / 0x00515465-0x0051548E) — on a steep roof it - // declared a non-walkable surface walkable; - // • HitGround has exactly ONE retail source, - // `set_on_walkable(1)` @0x00511358, which the per-tick - // SetPositionInternal commit now owns. Firing it from here - // as well would double-dispatch the landing re-apply; - // • retail never toggles GRAVITY_PS on a ground edge — see the - // per-tick commit's comment. - // What remains is AP-87's acdream-only snap, unchanged. - if (!rmState.Body.InContact) - { - rmState.Interp.Clear(); - rmState.Body.Position = worldPos; - rmState.Body.Orientation = rot; - - // C4 route 4a / D2: a landing packet is a GROUNDED - // correction, so retail's MoveOrTeleport returns nonzero - // and SmartBox::HandleReceivedPosition does arm the leash - // (@0x00454272). This block returns before the grounded - // routing below, so it arms its own — post-move, matching - // the anchor retail reads. The hard-coded NearInterpolate - // arm below is correct for every classification that can - // reach this block (A5 fix round, 2026-08-04) — NOT only - // Interpolate: SetPositionSimple (>=96m), null (login - // window), and RejectedAuthority/RejectedData all reach - // it too whenever the body lacks a contact plane, and - // NearInterpolate is an arming value for every one of - // them per D4's partition table. Only the teleport/ - // cell-less arm is excluded (D5: it dispatches earlier - // and returns before this block can be reached), and - // only NoPositionOperation (wire-airborne) is excluded - // (the AIRBORNE NO-OP return above). - RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - RuntimeRemoteAcceptedPositionArm.NearInterpolate, - rmState); - - // C4 route 4a: a landing packet classifies Interpolate, so - // the generic render-pose write was suppressed for it — - // this block must therefore commit the landing pose to the - // render entity itself, from the RESOLVED body, exactly as - // the two branch tails below do. Before route 4a the - // generic write had already put the entity at worldPos, - // which is the same value the snap above just installed; - // without this the rendered pose lags one frame until - // RemotePhysicsUpdater re-projects it. rmState.CellId is - // the server cell adopted at the top of this arm. - entity.SetPosition(rmState.Body.Position); - entity.ParentCellId = rmState.CellId; - entity.Rotation = rmState.Body.Orientation; - - // The motion bindings still have to exist before the next - // per-tick commit can dispatch this remote's ground edge. - // Only the HitGround CALL moved (see the block comment); - // binding is the packet's own responsibility. - if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) - && aeForLand.Sequencer is not null) - { - _motionRuntime.EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid); - } - - // Bug A investigation (2026-08-04, docs/ISSUES.md #32): - // the packet-side half of the landing capture. Bug B moved - // the HitGround call itself onto the per-tick - // `set_on_walkable` edge, so `hitGroundInvoked` is now - // false here and the "per-tick" pair is the one that - // reports the dispatch. This line still records the exact - // state the authoritative landing snap installed, which is - // what the discriminator table reads it for. - // TEMPORARY — strip once the live-test run has landed. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) - { - bool gravitySetForProbe = rmState.Body.HasGravity; - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding( - site: "controller", - guid: update.Guid, - airborneBefore: true, - gravitySet: gravitySetForProbe, - contact: rmState.Body.InContact, - onWalkable: rmState.Body.OnWalkable, - hasDefaultSink: rmState.Motion.DefaultSink is not null, - resolveIsOnGround: null, - sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, - sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0); - if (!gravitySetForProbe) - { - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( - "controller", update.Guid); - } - // Zero the sink-dispatch latches before reading them - // back. Nothing at THIS site dispatches — the arming - // call lives only next to the per-tick HitGround — so - // without this the line below would print - // sinkApplyCalls/sinkLastMotion/sinkLastResult left - // over from a previous per-tick capture on this - // thread and invite a reader to attribute them to the - // packet. Zeros are the honest report here. - AcDream.Core.Physics.PhysicsDiagnostics - .BeginRemoteLandingDispatchCapture(); - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter( - site: "controller", - guid: update.Guid, - hitGroundInvoked: false, - sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, - sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0, - forwardCommand: rmState.Motion.InterpretedState.ForwardCommand); - } - return; - } - - // ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ──────────── - // C4 routes 4a + 4b-2: the complete near/far/leftover decision - // is the SAME shared entry point the NPC arm below calls — - // retail's disassembly makes no `this == player` distinction - // on any of these branches. The player arm reaches it only - // with Body.InContact == true (the landing block above - // returns), so the free-flight carve-out inside is inert here. - // The teleport arm has already dispatched and returned above - // (D5), so `ApplyRemoteContactRouting`'s teleport check can - // never claim a packet here; the hook delegate is passed for - // shape only — there is exactly ONE hook implementation - // (RunRemoteTeleportHook), never a second path. - // - // R5 review fix: the currency guard (inside - // RunRemoteArmTail) sits BEFORE the leash arming, which is - // the same order the NPC arm has always had — the two arms - // were mirror images of each other and one of them had to be - // wrong. Arming is a write (it stamps rmState.Host's - // PositionManager), and this class's own rule is that - // nothing may be written through a superseded owner. The - // residual versus retail's unconditional arm on a nonzero - // MoveOrTeleport return is AP-138. - RemoteContactRouting? playerRoutingResult = RunRemoteArmTail( - acceptedPositionCanonical, - positionRecord, - rmState, - earlyRemoteRoute, - update.Guid, - worldPos, - rot, - () => IsCurrentPositionOwner(entity)); - if (playerRoutingResult is null) - return; - RemoteContactArm playerArm = playerRoutingResult.Value.Arm; - - // D2/D4: ConstrainTo arms strictly AFTER the operation, - // anchored post-move — retail arms it only once - // MoveOrTeleport returns nonzero (@0x00454254/@0x00454272), - // which the near, far, AND teleport branches all do - // (@0x005163BE, @0x005163E8, @0x00516438). The far and - // teleport branches arm on EVERY placement outcome, including - // a failed one: retail discards the placement's error and - // returns 1 regardless. This is now the ONLY arming site - // (the legacy pre-operation call is deleted — D4), and - // TryArmConstraintAfterOperation's own partition decides - // whether THIS arm arms. - // - // Delta review N4: retail's arm is unconditional, acdream's is - // not — the currency guard immediately above returns without - // arming when the far/teleport arm's synchronous receipt - // replaced or deleted this incarnation. That one-packet gap - // is AP-138(3), and applies to both re-entrant arms now. - RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - ToConstraintArm(playerArm), - rmState); - - // Track the UP-derived synth velocity for diagnostics - // ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the - // #39-era cycle-refinement call that used to live here is - // DELETED — retail never adapts a remote's animation from - // observed pace (deviation map DEV-2; premise refuted at - // decomp + ACE source + the S0 live capture, which shows - // explicit 0x0005↔0x0007 UMs on every Shift toggle). - // Player-remote cycles are UM-driven only. - if (rmState.PrevServerPosTime > 0.0) - { - double nowSecVel = rmState.LastServerPosTime; - double dtPos = nowSecVel - rmState.PrevServerPosTime; - if (dtPos > 0.001) - { - var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos; - rmState.ServerVelocity = synthVel; - rmState.HasServerVelocity = true; - } - } - - // Sync the visible entity to the body — overrides the - // entity.SetPosition(worldPos) write at the top of this - // function (TryApplyGenericRemoteRenderPose, suppressed for - // the two 4a classifications). This prevents a 1-frame - // teleport-then-yank-back rubber-band as TickAnimations - // chases worldPos via the queue. - // - // C4 route 4b-2 review fix — this used to claim "For the - // far-snap branch this is a no-op (body == worldPos)". The - // PROVENANCE claim behind that was always wrong and is what - // matters here: the far arm's body pose comes from the - // canonical accepted destination resolved through Runtime's - // world frame (a committed placement, a park's snap, or - // store_position), never from the caller's separately-derived - // worldPos, and this write is what carries that canonical pose - // to the render entity. - // - // Delta review N4 — the VALUE claim, stated correctly: on the - // store_position path the two happen to be equal, because #283 - // proved App's streaming origin and Runtime's world frame - // cannot disagree and both compose the same accepted origin. - // It is genuinely NOT a no-op on the committed path, where the - // body carries the collision-settled spherePath.CurPos. Do not - // "simplify" this write away on the strength of the equal case. - // - // #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 (pose-gated). Commit the complete root before the - // publication gate, matching SetPositionInternal ordering. - entity.SetPosition(rmState.Body.Position); - entity.ParentCellId = rmState.CellId; - entity.Rotation = rmState.Body.Orientation; - AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( - _liveEntities, - positionRecord, - entity, - rmState, - acceptedPositionAuthorityVersion, - () => _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, - rmState, - _origin.CenterX, - _origin.CenterY)); - return; - } - + // ── UNIFIED REMOTE ROUTING TAIL (OnPosition collapse, 2026-08-04) + // ──────────────────────────────────────────────────────────── + // Retail CPhysicsObj::MoveOrTeleport (0x00516330) makes NO + // this==player distinction on any branch of this decision — see + // ApplyRemoteContactRouting's own doc. This collapses the former + // player-guid and NPC-guid copies of the routing tail (L.3 M2 + // onward) into one guid-blind path. Two guid-conditionals + // survive, both named and justified rather than silently kept: + // • TS-44 sticky suppression (below) — an NPC-only steady-state + // gate; its own register row already describes it that way. + // • The AirborneSnap arm's interp-clear and collision-shadow + // publish (further below) — PRESERVED, not unified, because + // unifying either way would be an unauthorized behaviour + // change: #316 (filed 2026-08-04) is a real, UNMEASURED + // pre-existing player-guid defect (no shadow publish on + // landing) that this behaviour-preserving collapse must not + // fix, and the interp-clear's equivalence could not be proven + // for the steep-non-walkable-landing edge case (see the + // comment at that arm). + // nowSec is captured ONCE, shared by both guid ranges (was two + // independent DateTime.UtcNow reads before this collapse — a + // microsecond-scale skew in acdream-only bookkeeping/diagnostics). double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds; + // Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1), PLAYER-guid scope + // preserved exactly as before this collapse: roll the previous + // server-pos snapshot forward AND print the per-UP comparison + // between the max literal CSequence root-motion speed observed + // since the last UP and the actual server broadcast pace. Once + // the grounded-tail synth-velocity install below is unified onto + // the single NPC formula, Prev/PrevServerPosTime feed nothing but + // this print — kept as an explicitly-diagnostic step, not + // extended to NPC guids (which never had it and have no reader + // for it either). + if (IsPlayerGuid(update.Guid)) + { + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && rmState.LastServerPosTime > 0.0) + { + double dtServer = nowSec - rmState.LastServerPosTime; + if (dtServer > 0.001) + { + var serverDelta = worldPos - rmState.LastServerPos; + float serverSpeed = (float)(serverDelta.Length() / dtServer); + float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP; + if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f) + { + System.Console.WriteLine( + $"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s " + + $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s " + + $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}"); + } + } + } + rmState.MaxRootMotionSpeedSinceLastUP = 0f; + rmState.PrevServerPos = rmState.LastServerPos; + rmState.PrevServerPosTime = rmState.LastServerPosTime; + } + // ── AIRBORNE NO-OP (C4 route 4a / D1) ──────────────────────────── - // The exact mirror of the player-remote arm above. Retail's - // MoveOrTeleport makes no `this == player` distinction: arg4 == 0 - // returns 0 @0x0051636D and writes nothing, so an NPC remote's - // wire not-in-contact packet must no longer hard-snap the body, - // decide an animation cycle from a synthesized arc velocity, sync - // the render entity, or publish a collision shadow. This branch - // is now driven by the WIRE has_contact bit through the - // classifier, not by the client-tracked rmState.Airborne flag - // (which is what D1 was). - // - // Two acdream-only per-packet bookkeeping writes are deliberately - // KEPT here, exactly as the player arm has always kept them (see - // register row AP-135): the server cell id, which acdream's own - // per-tick free-fall ResolveWithTransition sweep gates on - // (rm.CellId != 0) and without which an airborne remote falls - // through the floor, and the last-server-position sample, without - // which the first grounded packet after the arc would synthesize - // its velocity across the whole jump. + // Retail CPhysicsObj::MoveOrTeleport (0x00516330): arg4 == 0 (the + // wire has_contact bit) falls straight to `return 0` @0x0051636D + // and writes NOTHING — not the body, not the interpolation queue, + // not the render entity, and (because ConstrainTo sits inside + // `if (MoveOrTeleport(...) != 0)` at @0x00454254) not the + // ConstraintManager leash either. AP-135's bookkeeping is + // acdream-only free-fall-sweep/first-grounded-velocity state, not + // a retail CPhysicsObj field, so it stays — now written through + // the shared helper for both guids (previously an inline copy for + // NPC, an implicit pre-write for the player). if (RuntimeRemoteSteadyStatePosition.IsAirborneNoOperation( earlyRemoteRoute)) { - rmState.CellId = p.LandblockId; - rmState.LastServerPos = worldPos; - rmState.LastServerPosTime = nowSec; + ApplyWireAirborneLeftoverBookkeeping( + rmState, p.LandblockId, worldPos, nowSec); return; } - // C4 route 4b-3 (D5): hoisted here (was computed further below, - // only for the sticky-gate widening) so the D2 check and the - // synth-velocity gate immediately below can both use it too. + // C4 route 4b-3 (D5): the teleport/cell-less classification is + // decided AHEAD of every contact carve-out — retail decides + // @0x00516386 before ever reading arg4 (@0x0051638E). Hoisted + // here so the D2 check and the synth-velocity gate below can both + // use it, and so ApplyRemoteContactRouting's own teleport + // dispatch (inside RunRemoteArmTail, below) is the ONLY teleport + // arm site for both guids — the former player-only pre-dispatch + // block is deleted; row 1's outcome-equivalence was verified + // call-site-by-call-site by the round-2 architecture review. bool isTeleportRoute = RuntimeRemoteTeleportPosition .OwnsTeleportPlacement(earlyRemoteRoute); - // C4 route 4b-3 (D2, R1/A7 fix round 2026-08-04): the NPC arm's - // own copy of the retail return-0 shape — previously this arm had - // NO wire-airborne early return for the null/RejectedAuthority/ - // RejectedData leftover set at all, so such a packet fell through - // to ApplyRemoteContactRouting's free-flight carve-out and - // received a body write, an arm, and a render/shadow publish - // retail's return 0 never produces. Shared with the player arm's - // identical call so the two cannot re-diverge on this shape - // again. Never fires for a teleport-classified route (D5 routes - // it ahead of every contact carve-out, including this one). + // C4 route 4b-3 (D2): retail's return-0 shape, applied to the two + // acdream-only leftover classifications that remain wire-airborne + // (null during the login window; RejectedAuthority/RejectedData). + // Never fires for a teleport-classified route (D5 routes it ahead + // of every contact carve-out, including this one). if (!update.IsGrounded && !isTeleportRoute) { ApplyWireAirborneLeftoverBookkeeping( @@ -2736,18 +2383,18 @@ internal sealed class LiveEntityNetworkUpdateController // hook's own CancelMoveTo already removes the moveto that would // otherwise have suppressed a synthesized run cycle, so this // must positively exclude the teleport route rather than rely on - // an incidental guard. Before this fix a teleport-classified NPC - // packet installed a ~teleport-distance/packet-interval - // synthesized velocity (often 1,000+ m/s) and - // RemoteServerControlledVelocityCycle.Apply (below) planned a - // RunForward cycle from it — a teleported creature visibly - // sprinted in place at the destination until the 0.6s stale- - // velocity watchdog fired. Leaving ServerVelocity/HasServerVelocity - // untouched here is safe: they are consumed only by the gated - // cycle-apply call below (also excluded for this same route), and - // the NEXT non-teleport packet recomputes them fresh from the - // (by-then-updated) LastServerPos, which no longer includes the - // teleport jump. + // an incidental guard. This is now the SINGLE synth-velocity + // install for both guids (the former player-guid grounded-tail + // copy, which derived from the Prev pair and fed nothing but its + // own [VEL_DIAG] print, is deleted): ServerVelocity/ + // HasServerVelocity have exactly two production readers — + // RemoteServerControlledVelocityCycle.Apply's internal + // IsPlayerGuid-gated return and RuntimeRemotePhysicsUpdater's + // !IsPlayerGuid-gated stale-velocity watchdog — so this write is + // observably inert for player guids (nothing reads it) and + // load-bearing for NPC guids, exactly as before the collapse. + // Reads the OLD LastServerPos/Time pair — this runs BEFORE the + // post-routing sample further below overwrites them. if (!isTeleportRoute) { System.Numerics.Vector3? serverVelocity = update.Velocity; @@ -2768,24 +2415,28 @@ internal sealed class LiveEntityNetworkUpdateController rmState.HasServerVelocity = false; } } - // R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping", - // stale facing, pushed-into-player): while an entity is STUCK, - // the sticky steer owns its frame — retail's UP corrections flow + + // R5-V3 #171 residual / TS-44 — THE ONE named surviving branch of + // this collapse (2026-07-04 gate: "flashing/flapping", stale + // facing, pushed-into-player): while an entity is STUCK, the + // sticky steer owns its frame — retail's UP corrections flow // through the InterpolationManager into the SAME adjust_offset // chain where StickyManager OVERWRITES them while armed // (PositionManager::adjust_offset 0x00555190 order; sticky - // assigns m_fOrigin 0x00555430), so a server correction can - // never fight an armed stick frame-by-frame. This legacy NPC - // path hard-snaps OUTSIDE that chain, producing a visible - // snap-out/steer-back oscillation at UP cadence (position) and - // a stale-facing stomp (orientation). Faithful translation to - // the snap architecture: suppress the position/orientation/ - // velocity snaps while stuck. LastServerPos/Time bookkeeping - // still records below — server truth reasserts on the first UP - // after unstick (bounded by the 1 s sticky lease). Register - // row with TS-41/TS-44. - bool snapSuppressedByStick = - (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; + // assigns m_fOrigin 0x00555430), so a server correction can never + // fight an armed stick frame-by-frame. NOT vacuous for players: + // LiveEntityMotionRuntimeController.StickToObjectFromWire (retail + // stick_to_object 0x005127e0, the mt-0 wire sticky trailer) can + // arm sticky on ANY remote host, player remotes included — but + // the player arm has never gated on it (only the NPC steady-state + // path did), and this collapse does not widen that: the TS-44 + // register row already describes exactly "an NPC-only + // steady-state gate" and stays true. Suppress the position/ + // orientation/velocity snaps while stuck; LastServerPos/Time + // bookkeeping still records below — server truth reasserts on the + // first UP after unstick (bounded by the 1 s sticky lease). + bool snapSuppressedByStick = !IsPlayerGuid(update.Guid) + && (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; if (snapSuppressedByStick && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled) { @@ -2794,38 +2445,27 @@ internal sealed class LiveEntityNetworkUpdateController Console.WriteLine(FormattableString.Invariant( $"[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})")); } - RemoteContactArm npcArm = RemoteContactArm.UnroutedCatchUp; - // C4 route 4b-3 (D5): TS-44's sticky suppression does NOT - // suppress the teleport arm — retail's sticky cannot survive a - // teleport (`UnStick` is the hook's second action, @0x00514EEE), - // so a stuck NPC's teleport packet must still run the hook and - // place. Widen the gate rather than adding a second dispatch - // path. (isTeleportRoute is hoisted above the D2 check now.) + + // C4 routes 4a + 4b-2 + 4b-3 collapse: the complete near/far/ + // teleport/leftover decision — including the dissolved LANDING + // TRANSITION block, now the AirborneSnap arm — is the ONE shared + // entry point every remote guid calls. `arm` defaults to + // UnroutedCatchUp for the sticky-suppressed case (D4: a stuck + // NPC's leash still re-arms — see the arming site below). + RemoteContactArm arm = RemoteContactArm.UnroutedCatchUp; if (!snapSuppressedByStick || isTeleportRoute) { - // C4 routes 4a + 4b-2 + 4b-3: the complete near/far/teleport/ - // leftover decision is the SAME shared entry point the - // player-remote branch above calls; retail's MoveOrTeleport - // (0x00516330) makes no `this == player` distinction, so the - // two per-kind copies became one. TS-44's sticky suppression - // stays an NPC-only CALLER gate (this `if`, now widened for - // the teleport arm only), which is what its register row - // describes and what the player arm has never had. + // C4 route 4b-3 (D5): TS-44's sticky suppression does NOT + // suppress the teleport arm — retail's sticky cannot survive + // a teleport (`UnStick` is the hook's second action, + // @0x00514EEE), so a stuck NPC's teleport packet must still + // run the hook and place. // - // #184 (2026-07-07): an AIRBORNE body keeps its authoritative - // hard-snap (the arc integrates locally, K-fix15), and that - // decision is taken FIRST — a landing packet classifies - // Interpolate, so letting route 4a's branch see it before the - // airborne test would enqueue a body that must plant, and a - // creature knocked off a ledge would glide down over a packet - // interval. Physics digest 2026-07-07 banner. The teleport - // arm is decided even earlier still, inside - // ApplyRemoteContactRouting itself (D5). - // - // Fix round (2026-08-04): the routing-decision-plus-currency - // sequence now runs through the SAME RunRemoteArmTail the - // player arm calls — see its doc for why (A1/A2/R3). - RemoteContactRouting? npcRoutingResult = RunRemoteArmTail( + // R5 review fix: the currency guard (inside RunRemoteArmTail) + // sits BEFORE the leash arming — arming is a write (it stamps + // rmState.Host's PositionManager), and nothing may be written + // through a superseded owner. + RemoteContactRouting? routing = RunRemoteArmTail( acceptedPositionCanonical, positionRecord, rmState, @@ -2834,62 +2474,175 @@ internal sealed class LiveEntityNetworkUpdateController worldPos, rot, () => IsCurrentPositionOwner(entity)); - if (npcRoutingResult is null) + if (routing is null) return; - npcArm = npcRoutingResult.Value.Arm; + arm = routing.Value.Arm; + + if (arm is RemoteContactArm.AirborneSnap) + { + // PRESERVED (rows 2a/2b of the collapse contract — NOT + // unified either way). This is the dissolved LANDING + // TRANSITION scenario: a grounded wire packet for a body + // with no contact plane. ApplyRemoteContactRouting's + // free-flight carve-out already wrote + // rmState.Body.Position/Orientation to the landing pose; + // ToConstraintArm(AirborneSnap) => NearInterpolate (the + // A1 fix) supplies the same arm value the old landing + // block hard-coded. + // + // #316 (filed 2026-08-04, deliberately NOT fixed here): + // the player-guid copy of this scenario has never + // published the collision shadow (the tail below does, + // for every OTHER arm and for this SAME arm on NPC + // guids) — a real, UNMEASURED pre-existing defect that + // contradicts the file's own #184 Slice 2b design intent + // ("player shadows now follow the resolved body ... + // exactly like NPCs"). Fixing it is a behaviour change + // this collapse may not make; the skip is reproduced + // verbatim at the tail below, keyed on this same `arm` + // value. + // + // The interp-queue clear is preserved alongside it rather + // than unified either way. AdjustOffset's CONTACT_TS gate + // (InterpolationManager.AdjustOffset: `if (!inContact) + // return ...;`, before EVER touching the queue) proves a + // populated queue is inert on an ordinary flat/walkable + // landing regardless of whether it was cleared here — the + // per-tick sweep's own `!previousOnWalkable && + // finalOnWalkable` edge (AP-139) clears it in the SAME + // tick that first reopens the CONTACT gate. But that + // per-tick clear is keyed to WALKABLE, not CONTACT, while + // AdjustOffset's gate is keyed to CONTACT — so a body + // that gains CONTACT on a non-walkable steep face (Bug + // B's own scenario: a remote landing on a roof) would + // have AdjustOffset's gate reopen one tick before the + // WALKABLE-keyed clear would ever fire, and a populated + // pre-arc queue would then be walked with stale + // waypoints. That edge case's equivalence could not be + // proven from either direction in the time this slice + // budgeted; shipping either "always clear" (a behaviour + // change for NPCs) or "never clear" (a behaviour change + // for players) on the strength of an incomplete proof is + // exactly what the contract forbids (§10 stop condition + // 2). Preserving both guids' exact pre-collapse behaviour + // is the safe outcome. + if (IsPlayerGuid(update.Guid)) + { + rmState.Interp.Clear(); + } + + // Resolved to UNIFY (idempotent + strictly additive): + // EnsureRemoteMotionBindings early-returns once rm.Host/ + // rm.Sink are already bound + // (LiveEntityMotionRuntimeController. + // EnsureRemoteMotionBindings:73), so "always ensure" is + // safe for both guids. The motion bindings still have to + // exist before the next per-tick commit can dispatch + // this remote's ground edge; an NPC that reaches this arm + // with no prior UM/OnVector binding had the identical + // latent gap the player copy already worked around. + if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) + && aeForLand.Sequencer is not null) + { + _motionRuntime.EnsureRemoteMotionBindings( + rmState, aeForLand, update.Guid); + } + + // Bug A investigation (2026-08-04, docs/ISSUES.md #32): + // the packet-side half of the landing capture, now fired + // for both guids (diagnostic-only — TEMPORARY, strip with + // the probe family; not behaviour). + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) + { + bool gravitySetForProbe = rmState.Body.HasGravity; + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding( + site: "controller", + guid: update.Guid, + airborneBefore: true, + gravitySet: gravitySetForProbe, + contact: rmState.Body.InContact, + onWalkable: rmState.Body.OnWalkable, + hasDefaultSink: rmState.Motion.DefaultSink is not null, + resolveIsOnGround: null, + sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, + sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0); + if (!gravitySetForProbe) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( + "controller", update.Guid); + } + // Zero the sink-dispatch latches before reading them + // back — nothing at THIS site dispatches (the arming + // call lives only next to the per-tick HitGround). + AcDream.Core.Physics.PhysicsDiagnostics + .BeginRemoteLandingDispatchCapture(); + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter( + site: "controller", + guid: update.Guid, + hitGroundInvoked: false, + sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, + sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0, + forwardCommand: rmState.Motion.InterpretedState.ForwardCommand); + } + } } - // D2/D4: ConstrainTo arms strictly AFTER the operation above, - // anchored post-move (@0x00454272, inside the - // `if (MoveOrTeleport(...) != 0)` at @0x00454254). This is now - // the ONLY arming site (the legacy pre-operation call is deleted - // — D4); TryArmConstraintAfterOperation's own partition decides - // whether THIS arm arms. Retail's ConstraintManager leash is - // independent of the acdream-only TS-44 sticky suppression - // (which only concerns the enqueue/snap/placement above), so this - // deliberately sits OUTSIDE the snapSuppressedByStick gate: a - // stuck NPC's leash still re-arms every accepted Position exactly - // as it did before this route split the single call into a - // per-branch pair. + // D2/D4: ConstrainTo arms strictly AFTER the operation, anchored + // post-move — retail arms it only once MoveOrTeleport returns + // nonzero (@0x00454254/@0x00454272), which the near, far, AND + // teleport branches all do (@0x005163BE, @0x005163E8, + // @0x00516438). This is the ONE arming site for every guid (the + // legacy pre-operation call and the two duplicated post-operation + // copies are deleted — D4); TryArmConstraintAfterOperation's own + // partition decides whether THIS arm arms. Sits OUTSIDE the + // snapSuppressedByStick gate: retail's ConstraintManager leash is + // independent of the acdream-only TS-44 suppression, so a stuck + // NPC's leash still re-arms every accepted Position (AP-138(3)'s + // one-packet unarmed residual on a superseded incarnation still + // applies — the currency guard inside RunRemoteArmTail returns + // above without reaching this call). RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - ToConstraintArm(npcArm), - rmState); - // K-fix15 (2026-04-26): DON'T auto-clear airborne on UP. - // ACE broadcasts UPs during the arc (peak / mid-fall / land) - // at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on - // the FIRST UP after the jump, which: - // * restored Contact + OnWalkable, - // * removed the Gravity flag, - // * caused the next per-tick to stomp Velocity via - // apply_current_movement (reading InterpretedState = - // Ready, so Velocity.Z went to 0), - // …so the body got stuck at the server-broadcast apex Z, - // visibly hovering. The fix: leave Airborne true; the - // per-tick post-resolve logic detects an actual landing - // (resolveResult.IsOnGround && Velocity.Z <= 0) and clears - // it then. Mirrors how PlayerMovementController re-grounds - // the local player at the bottom of its arc. - // - // The position-snap above is still authoritative — if ACE - // says the body is at Z=68 mid-arc, we render Z=68. But we - // continue integrating gravity from there, so the body - // proceeds along the parabolic path between UPs. - // Adopt the server's cell ID as the transition starting cell. - // Retail authoritatively hard-snaps cell membership here too; our - // per-tick ResolveWithTransition sweep then advances CheckCellId - // as the sphere crosses cells and writes the new cell back into - // rmState.CellId so the NEXT frame starts in the correct cell. - // - // C4 route 4b-2: NOT after a far snap — see - // TryAdoptWireCellAfterRouting for the rule and why it applies to - // this arm and not the player one. - TryAdoptWireCellAfterRouting(rmState, npcArm, p.LandblockId); + ToConstraintArm(arm), rmState); + + // C4 route 4b-2/4b-3: NOT after a far snap or a teleport — see + // TryAdoptWireCellAfterRouting for the suppression rule. The ONE + // post-routing wire-cell adopt site for every guid (proof + // obligation re-verified for this collapse: the constraint + // anchor reads host.Position, never rm.CellId; ApplyInterpolate, + // the free-flight carve-out, and WillAdvanceRemoteMotion do not + // read it either; RebucketLiveEntity, above, already committed + // the wire cell into RuntimeEntityRecord.FullCellId — which + // RemoteMotion.CellId's getter reads through to — before ANY of + // this method's guid branching runs, so deleting the former + // player-guid copy's redundant pre-routing write is a true + // no-op, exactly as the round-2 architecture review's call-site + // check already found for the extracted-helper case). + TryAdoptWireCellAfterRouting(rmState, arm, p.LandblockId); // Near UpdatePosition orientation is carried by the same complete // interpolation Frame as translation. Placement, airborne, and // far-correction branches above install the authoritative Frame // directly. Sticky still receives the shared Frame afterward and // may replace it while armed. + // + // Resolved to UNIFY: ONE post-routing sample for every guid. + // Equivalence walk (ApplyInterpolate's AP-87 backstop reads + // `firstUp = remote.LastServerPosTime <= 0.0`): on a genuine + // first UP the RemoteMotion was created THIS packet with + // Body.Position == worldPos (the creation branch above), so + // bodyToTarget == 0 regardless of which guid's firstUp timing + // applies. The (until now) NPC-timed firstUp=true path takes + // ApplyInterpolate's Snapped branch — a no-op body/orientation + // write at zero distance, an idempotent queue clear. The + // (until now) player-timed firstUp=false-but-zero-distance path + // takes InterpolationManager.Enqueue's "already-close" branch + // (also an idempotent queue clear, and an orientation write + // derived from the SAME wire orientation via + // SetHeading(o, GetHeading(o)) — read verbatim, not assumed, + // and identical for the heading-only quaternions every remote's + // wire Position carries). Sampling once, after routing, is + // therefore unobservable versus the former player-guid + // pre-routing stamp. rmState.LastServerPos = worldPos; rmState.LastServerPosTime = nowSec; @@ -2898,21 +2651,17 @@ internal sealed class LiveEntityNetworkUpdateController && !snapSuppressedByStick && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) { - // NPC/monster remotes: PlanFromVelocity cycle selection from - // UP-derived velocity (ACE broadcasts NPC motion patterns the - // UM stream alone doesn't cover). Player remotes return early - // inside — their cycles are UM-driven only per retail (L.2g - // S5; DEV-2 deleted). Unification of NPCs onto the - // CMotionInterp funnel is S6. - // - // R3/A2 fix round (2026-08-04): excluded for a teleport- - // classified route above (isTeleportRoute) — retail's - // teleport branch writes no velocity at all, so there is - // nothing here to plan a cycle from. - // - // D2 (Commit A 2026-05-03): tag whether the velocity feeding - // ApplyServerControlledVelocityCycle is wire-explicit or - // synthesized from position deltas (the common case). + // The second data-driven-not-branching survivor of this + // collapse: RemoteServerControlledVelocityCycle.Apply's own + // internal IsPlayerGuid early return (AP-80/DEV-2) is now the + // ONLY carrier of the player/NPC animation-cycle distinction + // — this call site is guid-blind. Player remotes' cycles stay + // UM-driven only per retail (L.2g S5; DEV-2 deleted); NPC/ + // monster remotes keep PlanFromVelocity cycle selection from + // UP-derived velocity. Excluded for a teleport-classified + // route above (isTeleportRoute) — retail's teleport branch + // writes no velocity at all, so there is nothing here to plan + // a cycle from. if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { string velSrc = update.Velocity is null ? "synth" : "wire"; @@ -2926,28 +2675,38 @@ internal sealed class LiveEntityNetworkUpdateController rmState.ServerVelocity); } - // #184: sync the NPC shadow to the resolved/placed body (NOT the raw - // 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, pose-gated. rmState.CellId is the server cell adopted above. - // The root frame is committed before collision publication, as in - // retail SetPositionInternal. + // #184: sync the shadow to the resolved/placed body (NOT the raw + // 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 remotes (which the per-tick loop skips). The + // per-tick loop keeps it current between UPs, pose-gated. + // rmState.CellId is the server cell adopted above. The root + // frame is committed before collision publication, as in retail + // SetPositionInternal. The ONE entity-sync + shadow-publish tail + // for every guid and every arm — except the #316-preserved + // exception: a player-guid AirborneSnap arm still commits the + // render entity from the resolved body but does NOT publish the + // shadow, matching its pre-collapse behaviour exactly (see the + // comment at that arm, above). entity.SetPosition(rmState.Body.Position); entity.ParentCellId = rmState.CellId; entity.Rotation = rmState.Body.Orientation; - AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( - _liveEntities, - positionRecord, - entity, - rmState, - acceptedPositionAuthorityVersion, - () => _remotePhysicsUpdater.SyncRemoteShadowToBody( - entity.Id, + if (arm is not RemoteContactArm.AirborneSnap + || !IsPlayerGuid(update.Guid)) + { + AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( + _liveEntities, + positionRecord, + entity, rmState, - _origin.CenterX, - _origin.CenterY)); + acceptedPositionAuthorityVersion, + () => _remotePhysicsUpdater.SyncRemoteShadowToBody( + entity.Id, + rmState, + _origin.CenterX, + _origin.CenterY)); + } } // F751 is only a notification gate; the accepted Position may arrive diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs index 7d853f0e..9d235319 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkBranchRoutingTests.cs @@ -120,9 +120,9 @@ public sealed class LiveEntityNetworkBranchRoutingTests } /// - /// AP-140 (retired 2026-08-04): the SECOND accepted-Position routing - /// gate — OnPosition's player-remote landing block — must - /// select the hard snap on retail's CONTACT predicate + /// AP-140 (retired 2026-08-04): the accepted-Position routing gate + /// for the free-flight/landing decision must select the hard snap on + /// retail's CONTACT predicate /// (InterpolationManager::adjust_offset @0x00555D30 gates its /// whole body on transient_state & 1 @0x00555D52, and bit 0 /// is CONTACT_TS), not on the client Airborne flag, @@ -130,13 +130,23 @@ public sealed class LiveEntityNetworkBranchRoutingTests /// set that also captures a remote sliding on a steep face. /// /// + /// C4 route 4b-3's OnPosition collapse (2026-08-04) dissolved + /// OnPosition's former standalone player-remote LANDING + /// TRANSITION block — which used to carry its own + /// if (!rmState.Body.InContact) copy of this gate — into + /// ApplyRemoteContactRouting's free-flight carve-out, now the + /// ONE site (for every guid) that decides this. The gate itself did + /// not move in spirit, only in address: this pin follows it there. + /// + /// + /// /// A source pin rather than a behavioural fixture for the reason this /// class already documents: the controller's dependency set is - /// composition-only. The twin gate inside - /// ApplyRemoteContactRouting — a static method, so reachable — - /// IS covered behaviourally, in + /// composition-only. The gate inside ApplyRemoteContactRouting + /// — a static method, so reachable — IS covered behaviourally, in /// LiveEntityNetworkRemoteSteadyStateIntegrationTests. Restore - /// if (rmState.Airborne) here and this test fails. + /// if (rmState.Airborne) or if (remote.Airborne) here + /// and this test fails. /// /// [Fact] @@ -145,7 +155,7 @@ public sealed class LiveEntityNetworkBranchRoutingTests string source = ReadSource("LiveEntityNetworkUpdateController.cs"); Assert.Contains( - "if (!rmState.Body.InContact)", + "if (!remote.Body.InContact)", source, StringComparison.Ordinal); // `rmState.Airborne` survives as a WRITE target and in prose (the diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs new file mode 100644 index 00000000..d2fb1ca9 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -0,0 +1,1243 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.Update; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using DatReaderWriter.Types; + +// Alias the DatReaderWriter enum so it doesn't clash with +// AcDream.Core.Physics.MotionCommand (a static class of uint constants). +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.App.Tests.Physics; + +/// +/// The OnPosition collapse's referee (contract §5, +/// docs/research/2026-08-04-onposition-collapse-contract.md): every scenario +/// below is driven for BOTH a 0x50xxxxxx player-range guid and a +/// 0x8xxxxxxx creature-range guid through the SAME production +/// entry point — +/// never the extracted ApplyRemoteContactRouting seam directly. Before +/// the collapse this class did not exist as a structural pattern: every +/// existing test in this file's siblings drove exactly one guid range, which +/// is precisely how the 4b-3 defects (A1's zero-arm leash regression, A2/R3's +/// synthesized-velocity defect) survived — a defect in one hand-written copy +/// left the other copy's tests green. After the collapse there is one code +/// path, so running both guids through it is cheap; the point is it STAYS a +/// pair so a future re-divergence (a guid-gated edit smuggled into the +/// unified tail) fails a test instead of hiding. +/// +/// +/// This class reuses the +/// fixture's exact composition-only construction pattern (the controller's +/// 67+ collaborators are wired only by SessionPlayerComposition in +/// production, so a source pin would be the alternative — this drives the +/// real class instead). +/// +/// +public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests +{ + private const uint SourceLandblock = 0xB1000000u; + private const uint SourceCell = SourceLandblock | 0x0001u; + private const uint DestinationLandblock = 0xB2000000u; + private const uint DestinationCell = DestinationLandblock | 0x0001u; + private static readonly Vector3 DestinationWorldOffset = new(192f, 0f, 0f); + + /// Player-range guid — 0x50xxxxxx, distinct from the + /// fixture's own local-player NoopIdentitySource.ServerGuid + /// (0x50000099u). + private const uint PlayerGuid = 0x50007001u; + + /// Creature-range guid — 0x8xxxxxxx, matching the + /// connected-gate evidence in the 4b-3 contract for how real creature + /// guids arrive on the wire. + private const uint CreatureGuid = 0x80007001u; + + private const float SpawnHeight = 7f; + private const float FootSphereCenterLift = 0.48f; + + private static bool IsPlayer(uint guid) => guid == PlayerGuid; + + // ── Scenario 1: teleport commit ───────────────────────────────────── + + [Theory] + [InlineData(PlayerGuid)] + [InlineData(CreatureGuid)] + public void TeleportCommit_BothGuids_ArmsOnceAndNeverInstallsVelocity(uint guid) + { + using var fixture = new Fixture(guid); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + var destination = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + destination, DestinationCell, teleportSequence: 5, guid: guid)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + guid, out RuntimeEntityRecord canonical)); + Assert.NotNull(canonical.PhysicsBody); + PhysicsBody body = canonical.PhysicsBody!; + Vector3 resolved = destination + DestinationWorldOffset + + new Vector3(0f, 0f, FootSphereCenterLift); + Assert.Equal(resolved, body.Position); + Assert.Equal(body.Position, fixture.Entity.Position); + Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + Assert.Equal(DestinationCell, canonical.FullCellId); + + // The leash armed exactly once (D4: teleport arms on every outcome). + Assert.NotNull(host.PositionManager.Constraint); + + // Row 6/invariant-6 (contract §5 scenario 1): retail's teleport + // branch writes NO velocity at all, for either guid. + Assert.False(fixture.Remote.HasServerVelocity); + Assert.Equal(Vector3.Zero, fixture.Remote.ServerVelocity); + + // The shadow published at the resolved position. + ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(body.Position, shadowEntry.Position); + + fixture.DrainPlacementFifo(); + } + + // ── Scenario 2: landing packet (the preserved rows 2a/2b asymmetry) ─ + + [Fact] + public void LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved() + { + using var fixture = new Fixture(PlayerGuid); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + // Mid-arc: body not in contact, wire IS grounded — the LANDING + // scenario. Pre-populate the interp queue so the clear is observable. + fixture.Remote.Body.TransientState = TransientStateFlags.Active; + fixture.Remote.Interp.Enqueue( + new Vector3(1f, 1f, SpawnHeight), + Quaternion.Identity, + isMovingTo: false, + currentBodyPosition: new Vector3(50f, 50f, SpawnHeight), + currentBodyOrientation: Quaternion.Identity); + Assert.True(fixture.Remote.Interp.IsActive); + Vector3 spawnShadowPos = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id).Position; + var landingPos = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + landingPos, SourceCell, teleportSequence: 1, + guid: PlayerGuid, isGrounded: true)); + + // Body snapped to the landing pose; entity synced from the resolved + // body; armed exactly once. + Assert.Equal(landingPos, fixture.Remote.Body.Position); + Assert.Equal(landingPos, fixture.Entity.Position); + Assert.Equal(SourceCell, fixture.Entity.ParentCellId); + Assert.NotNull(host.PositionManager.Constraint); + + // Row 2a, PRESERVED for player guids: the interp queue IS cleared. + Assert.False(fixture.Remote.Interp.IsActive); + + // Row 2b / #316, PRESERVED (NOT fixed): the shadow is NOT + // republished for a player-guid landing — it stays at whatever it + // was before this packet. + ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(spawnShadowPos, shadowEntry.Position); + Assert.NotEqual(landingPos, shadowEntry.Position); + + // AP-135's cell-adopt bookkeeping still ran (via the post-routing + // wire-cell adopt, not suppressed for AirborneSnap). + Assert.Equal(SourceCell, fixture.Remote.CellId); + } + + [Fact] + public void LandingPacket_CreatureGuid_ShadowPublishedQueueNotCleared() + { + using var fixture = new Fixture(CreatureGuid); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + fixture.Remote.Body.TransientState = TransientStateFlags.Active; + fixture.Remote.Interp.Enqueue( + new Vector3(1f, 1f, SpawnHeight), + Quaternion.Identity, + isMovingTo: false, + currentBodyPosition: new Vector3(50f, 50f, SpawnHeight), + currentBodyOrientation: Quaternion.Identity); + Assert.True(fixture.Remote.Interp.IsActive); + var landingPos = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + landingPos, SourceCell, teleportSequence: 1, + guid: CreatureGuid, isGrounded: true)); + + Assert.Equal(landingPos, fixture.Remote.Body.Position); + Assert.Equal(landingPos, fixture.Entity.Position); + Assert.NotNull(host.PositionManager.Constraint); + + // Row 2a, PRESERVED for creature guids: the interp queue is NOT + // cleared here (the per-tick AP-139 edge owns it — see the arm's + // comment in OnPosition). + Assert.True(fixture.Remote.Interp.IsActive); + + // Row 2b, the NPC/creature half: the shadow DOES publish at the + // resolved body pose — this is the behaviour #316 says the player + // half is missing. + ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(landingPos, shadowEntry.Position); + } + + // ── Scenario 3: wire-airborne, null-classified (login-window shape) ─ + + [Theory] + [InlineData(PlayerGuid)] + [InlineData(CreatureGuid)] + public void WireAirborneNullClassified_BothGuids_WritesOnlyAP135Bookkeeping( + uint guid) + { + using var fixture = new Fixture(guid, nullClassification: true); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + Vector3 spawnBodyPose = fixture.Remote.Body.Position; + var wirePos = new Vector3(50f, 50f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + wirePos, SourceCell, teleportSequence: 1, + guid: guid, isGrounded: false)); + + // No body write. + Assert.Equal(spawnBodyPose, fixture.Remote.Body.Position); + // No shadow republish. + ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(spawnBodyPose, shadowEntry.Position); + // No arm (round-2 architecture review's discriminator: Constraint is + // lazily created only on a genuine arm). + Assert.Null(host.PositionManager.Constraint); + + // Positive half (round-2 B1's lesson): AP-135's two writes DID + // happen. + Assert.Equal(SourceCell, fixture.Remote.CellId); + Assert.Equal(wirePos, fixture.Remote.LastServerPos); + Assert.NotEqual(0d, fixture.Remote.LastServerPosTime); + } + + // ── Scenario 4: airborne no-op (NoPositionOperation, non-null route) ─ + + [Theory] + [InlineData(PlayerGuid)] + [InlineData(CreatureGuid)] + public void AirborneNoOperation_BothGuids_WritesOnlyAP135BookkeepingNoArm( + uint guid) + { + using var fixture = new Fixture(guid); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + // Contact SET so this is NOT the landing/AirborneSnap scenario — + // this packet must classify NoPositionOperation via the WIRE + // has_contact bit (update.IsGrounded), the classifier's OWN + // predicate, independent of the body's own contact state. + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + Vector3 spawnBodyPose = fixture.Remote.Body.Position; + var wirePos = new Vector3(50f, 50f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + wirePos, SourceCell, teleportSequence: 1, + guid: guid, isGrounded: false)); + + Assert.Equal(spawnBodyPose, fixture.Remote.Body.Position); + ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(spawnBodyPose, shadowEntry.Position); + Assert.Null(host.PositionManager.Constraint); + + Assert.Equal(SourceCell, fixture.Remote.CellId); + Assert.Equal(wirePos, fixture.Remote.LastServerPos); + Assert.NotEqual(0d, fixture.Remote.LastServerPosTime); + } + + // ── Scenario 5: near interpolate ───────────────────────────────────── + + [Theory] + [InlineData(PlayerGuid)] + [InlineData(CreatureGuid)] + public void NearInterpolate_BothGuids_EnqueuesAndArmsOnce(uint guid) + { + using var fixture = new Fixture(guid); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + // AP-87 backstop: not firstUp, and further than the 4 m snap + // threshold so this packet enqueues rather than snaps. + fixture.Remote.Body.Position = new Vector3(2f, 2f, SpawnHeight); + fixture.Remote.LastServerPos = fixture.Remote.Body.Position; + fixture.Remote.LastServerPosTime = + (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds - 0.15; + // Within AP-87's 4 m snap threshold (distance ~2.24 m) but well + // outside InterpolationManager.Enqueue's 0.05 m "already-close" wipe + // — a genuine enqueue, not a snap and not a no-op. + var target = new Vector3(4f, 3f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + target, SourceCell, teleportSequence: 1, + guid: guid, isGrounded: true)); + + // Enqueued, not snapped: the body pose is unchanged (the per-tick + // catch-up walks toward the queued target — this packet does not + // move the body directly). + Assert.Equal(new Vector3(2f, 2f, SpawnHeight), fixture.Remote.Body.Position); + Assert.True(fixture.Remote.Interp.IsActive); + Assert.NotNull(host.PositionManager.Constraint); + // Row 3: the wire cell still adopts (near-interpolate is not a + // placement arm, so TryAdoptWireCellAfterRouting does not suppress). + Assert.Equal(SourceCell, fixture.Remote.CellId); + // The render entity tracks the (unchanged) body, not the wire pose — + // route 4a's generic-write suppression plus the unified tail's + // resync from the resolved body. + Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position); + } + + // ── Scenario 6: far snap ───────────────────────────────────────────── + + [Theory] + [InlineData(PlayerGuid)] + [InlineData(CreatureGuid)] + public void FarSnap_BothGuids_PlacesAndArmsOnEveryOutcome(uint guid) + { + using var fixture = new Fixture(guid); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + Vector3 spawnPose = fixture.Entity.Position; + // Far enough (>=96 m from the stub player controller's origin) via a + // packet whose destination cell differs, WITHOUT advancing + // TELEPORT_TS — the SetPositionSimple (far) classification, not + // SetPosition (teleport/cell-less). + var destination = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + destination, DestinationCell, teleportSequence: 1, + guid: guid, isGrounded: true)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + guid, out RuntimeEntityRecord canonical)); + Assert.NotNull(canonical.PhysicsBody); + PhysicsBody body = canonical.PhysicsBody!; + Assert.NotEqual(spawnPose, body.Position); + Assert.Equal(body.Position, fixture.Entity.Position); + Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + Assert.Equal(DestinationCell, canonical.FullCellId); + Assert.NotNull(host.PositionManager.Constraint); + + ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(body.Position, shadowEntry.Position); + + fixture.DrainPlacementFifo(); + } + + // ── Scenario 7: sticky-suppressed steady-state (row 8's asymmetry) ── + + [Fact] + public void StickySuppressed_CreatureGuid_RoutingSkippedButStillArmed() + { + using var fixture = new Fixture(CreatureGuid); + EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009999u); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + Vector3 bodyBefore = fixture.Remote.Body.Position; + var target = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + target, SourceCell, teleportSequence: 1, + guid: CreatureGuid, isGrounded: true)); + + // Routing never ran: body and queue are untouched. + Assert.Equal(bodyBefore, fixture.Remote.Body.Position); + Assert.False(fixture.Remote.Interp.IsActive); + // D4: sticky-suppressed still arms, with UnroutedCatchUp. + Assert.NotNull(host.PositionManager.Constraint); + // Still stuck — nothing in this path unsticks it (only the teleport + // hook does). + Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId()); + } + + [Fact] + public void StickySuppressed_PlayerGuid_GateNeverAppliesRoutingRunsAnyway() + { + using var fixture = new Fixture(PlayerGuid); + EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009999u); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + fixture.Remote.Body.Position = new Vector3(2f, 2f, SpawnHeight); + fixture.Remote.LastServerPos = fixture.Remote.Body.Position; + fixture.Remote.LastServerPosTime = + (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds - 0.15; + var target = new Vector3(4f, 3f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.Update( + target, SourceCell, teleportSequence: 1, + guid: PlayerGuid, isGrounded: true)); + + // Row 8's named asymmetry: TS-44 never gates a player guid, so + // routing ran despite sticky being armed — the interp queue is now + // populated (or the body snapped), unlike the creature case above. + Assert.True(fixture.Remote.Interp.IsActive); + Assert.NotNull(host.PositionManager.Constraint); + // Sticky itself is untouched by this non-teleport path (only the + // teleport hook unsticks). + Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId()); + } + + // ── Scenario 8: NPC velocity-cycle (row 7/14's data-driven survivor) ─ + + [Fact] + public void VelocityCycle_CreatureGuid_PlansACycleFromWireVelocity() + { + using var fixture = new Fixture(CreatureGuid, withAnimation: true); + EntityPhysicsHost host = fixture.InstallHost(); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + // InitializeState() (fixture ctor) already seeded Ready via the + // motion table's StyleDefaults — verify the precondition rather + // than re-stating the seed. + Assert.Equal( + AcDream.Core.Physics.MotionCommand.Ready, + fixture.Animated!.Sequencer!.CurrentMotion); + var target = new Vector3(12f, 14f, SpawnHeight); + var wireVelocity = new Vector3(0.7f, 0f, 0f); + + fixture.Controller.OnPosition(fixture.Update( + target, SourceCell, teleportSequence: 1, + guid: CreatureGuid, isGrounded: true, velocity: wireVelocity)); + + Assert.True(fixture.Remote.HasServerVelocity); + Assert.Equal(wireVelocity, fixture.Remote.ServerVelocity); + Assert.NotEqual( + AcDream.Core.Physics.MotionCommand.Ready, + fixture.Animated.Sequencer.CurrentMotion); + _ = host; + } + + [Fact] + public void VelocityCycle_PlayerGuid_SequencerUntouchedByWireVelocity() + { + using var fixture = new Fixture(PlayerGuid, withAnimation: true); + EntityPhysicsHost host = fixture.InstallHost(); + fixture.Remote.Body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact; + Assert.Equal( + AcDream.Core.Physics.MotionCommand.Ready, + fixture.Animated!.Sequencer!.CurrentMotion); + var target = new Vector3(12f, 14f, SpawnHeight); + var wireVelocity = new Vector3(0.7f, 0f, 0f); + + fixture.Controller.OnPosition(fixture.Update( + target, SourceCell, teleportSequence: 1, + guid: PlayerGuid, isGrounded: true, velocity: wireVelocity)); + + // Row 6: the synth-velocity write is still write-only for players — + // it happens (the unified NPC formula runs for every guid), but + // nothing production reads it here either. + Assert.True(fixture.Remote.HasServerVelocity); + // Row 7/14: RemoteServerControlledVelocityCycle.Apply's internal + // IsPlayerGuid return means the sequencer NEVER changes for a player + // guid, regardless of the wire velocity supplied. + Assert.Equal( + AcDream.Core.Physics.MotionCommand.Ready, + fixture.Animated.Sequencer.CurrentMotion); + _ = host; + } + + // ── Sabotage check (contract §5, one-time, manual) ────────────────── + // + // Performed by hand during implementation, not committed as a test (a + // committed sabotage would defeat its own purpose): temporarily deleted + // the `RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation` + // call from the unified tail in `OnPosition` and re-ran this file. + // Result: TeleportCommit_BothGuids (both InlineData rows), + // LandingPacket_PlayerGuid, LandingPacket_CreatureGuid, + // NearInterpolate_BothGuids (both rows), FarSnap_BothGuids (both rows), + // and StickySuppressed_CreatureGuid / StickySuppressed_PlayerGuid all + // failed — 9 of 9 non-airborne scenarios, both guid halves of every + // dual-guid Theory. This is the structural fix for the 4b-3 defect + // class: a sabotage that used to leave one guid's tests green now fails + // both, because there is one call site left to sabotage. Reverted before + // committing. + + private sealed class Fixture : IDisposable + { + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal LiveEntityRuntime Runtime { get; } + internal LiveEntityNetworkUpdateController Controller { get; } + internal RemoteServiceWindow ServiceWindow { get; } = new(); + internal ShadowObjectRegistry Shadows { get; } + internal WorldEntity Entity { get; } + internal RemoteMotion Remote { get; private set; } = null!; + internal LiveEntityAnimationState? Animated { get; private set; } + private readonly GpuWorldState _spatial; + private readonly uint _guid; + private readonly bool _nullClassification; + + internal Fixture( + uint guid, + bool nullClassification = false, + bool withAnimation = false) + { + _guid = guid; + _nullClassification = nullClassification; + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + SourceLandblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime = new RuntimeEntityObjectLifetime(engine); + Lifetime.BindEventContext( + static () => new RuntimeGenerationToken(1UL), + static () => 1UL); + Shadows = engine.ShadowObjects; + + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + CanonicalLandblock(SourceLandblock), + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + _spatial = spatial; + Runtime = new LiveEntityRuntime( + spatial, + new NoopResources(), + NullLiveEntityRuntimeComponentLifecycle.Instance, + Lifetime); + + var wirePosition = new CreateObject.ServerPosition( + SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 1, + ServerControlledMove: 1, + ForcePosition: 1, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: wirePosition, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: 1f, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + var spawn = new WorldSession.EntitySpawn( + _guid, + wirePosition, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "onposition-collapse-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + PositionSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + Physics: physics); + LiveEntityRecord record = + Runtime.RegisterAndMaterializeProjection(spawn); + Entity = record.WorldEntity + ?? throw new InvalidOperationException( + "fixture failed to materialize the remote entity"); + Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell)); + + var remote = new RemoteMotion(); + remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position); + remote.CellId = SourceCell; + Runtime.SetRemoteMotionRuntime(_guid, remote); + Remote = remote; + Shadows.Register( + Entity.Id, + 0x02000001u, + Entity.Position, + Entity.Rotation, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: SourceLandblock, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f, + seedCellId: SourceCell, + isStatic: false); + + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder( + (int)((SourceLandblock >> 24) & 0xFFu), + (int)((SourceLandblock >> 16) & 0xFFu)); + + LiveEntityAnimationRuntimeView animatedEntities; + if (withAnimation) + { + var slot = new LiveEntityRuntimeSlot(); + slot.Bind(Runtime); + animatedEntities = + new LiveEntityAnimationRuntimeView(slot); + // A working (style, motion) -> animation table for BOTH + // Ready and WalkForward, so SetCycle's dispatch through + // PerformMovement genuinely succeeds and CurrentMotion + // actually changes — a bare `new MotionTable()` has no + // cycles, so every SetCycle silently no-ops + // (AnimationSequencer.SetCycle: "Failed dispatch ... leaves + // sequence AND state untouched"), which would make every + // scenario 8 assertion vacuously true regardless of the + // collapse. Pattern mirrors + // tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs's + // own `Fixtures.MakeMtable`. + const uint animStyle = 0x8000003Du; + const uint readyAnimId = 0x03000101u; + const uint walkAnimId = 0x03000102u; + var mt = new DatReaderWriter.DBObjs.MotionTable + { + DefaultStyle = (DRWMotionCommand)animStyle, + }; + mt.StyleDefaults[(DRWMotionCommand)animStyle] = + (DRWMotionCommand)AcDream.Core.Physics.MotionCommand.Ready; + mt.Cycles[(int)((animStyle << 16) + | (AcDream.Core.Physics.MotionCommand.Ready & 0xFFFFFFu))] = + MakeMotionData(readyAnimId); + mt.Cycles[(int)((animStyle << 16) + | (AcDream.Core.Physics.MotionCommand.WalkForward & 0xFFFFFFu))] = + MakeMotionData(walkAnimId); + var loader = new FakeAnimationLoader(); + loader.Register(readyAnimId, MakeTwoFrameAnim()); + loader.Register(walkAnimId, MakeTwoFrameAnim()); + var setup = new DatReaderWriter.DBObjs.Setup(); + setup.Parts.Add(0x01000000u); + setup.DefaultScale.Add(Vector3.One); + var sequencer = new AcDream.Core.Physics.AnimationSequencer( + setup, mt, loader); + sequencer.InitializeState(); + Animated = new LiveEntityAnimationState + { + Entity = Entity, + Setup = setup, + Animation = new DatReaderWriter.DBObjs.Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = 1f, + PartTemplate = Array.Empty(), + PartAvailability = Array.Empty(), + Sequencer = sequencer, + }; + animatedEntities[Entity.Id] = Animated; + } + else + { + animatedEntities = + new LiveEntityAnimationRuntimeView( + new LiveEntityRuntimeSlot()); + } + + var remotePlacementDrive = new RuntimeRemotePlacementDriveController( + Lifetime, + new GameRuntimeClock(), + new NoopCollisionSource(), + ServiceWindow); + var acceptedPositionDrive = new RuntimeAcceptedPositionDriveController( + Lifetime, + new GameRuntimeClock(), + new NoopCollisionSource(), + new LocalPlayerOutboundController(static (_, _, _, _, _, _) => { }), + static () => new RuntimeGenerationToken(1UL), + static () => 0x50000099u, + static () => null, + static () => false, + static () => null); + var identity = new NoopIdentitySource(); + var deletion = new LiveEntityDeletionController( + Runtime, + Lifetime, + new NoopTeardownCoordinator(), + identity); + var hydration = new LiveEntityHydrationController( + Runtime, + Lifetime, + new object(), + new NoopMaterializer(), + new NoopRelationships(), + new NoopReadyPublisher(), + new AlwaysKnownOrigin(), + new NoopNetworkSink(), + new NoopTimestampPublisher(), + identity, + deletion); + var entityEffects = new EntityEffectController( + Runtime, + new AcDream.Core.Vfx.PhysicsScriptRunner( + static _ => null, + new AcDream.Core.Physics.AnimationHookRouter(), + randomUnit: static () => 0.5), + new AcDream.Core.Vfx.PhysicsScriptTableResolver(static _ => null), + new EntityEffectPoseRegistry()); + + Controller = new LiveEntityNetworkUpdateController( + Runtime, + Lifetime.Objects, + hydration, + entityEffects, + new LiveEntityPresentationController( + Runtime, + Shadows, + (_, _, _) => true, + new LiveEntityPartArrayEnterWorldPort(_ => { })), + new LiveEntityLightController( + Runtime, + new EntityEffectPoseRegistry(), + new AcDream.Core.Lighting.LightingHookSink( + new AcDream.Core.Lighting.LightManager(), + new EntityEffectPoseRegistry()), + static _ => null), + new EquippedChildRenderController( + new NoopDatReaderWriter(), + new object(), + Lifetime.Objects, + Runtime, + new EntityEffectPoseRegistry(), + static _ => false, + static (_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Superseded, + null)), + new ProjectileController(Runtime), + animatedEntities, + new RemoteMovementObservationTracker(), + new RemotePhysicsUpdater( + Lifetime.Physics, + static (_, _) => (0.48f, 1.835f), + static (_, _) => ( + System.Collections.Immutable + .ImmutableArray.Empty, + 1f, 0.4f, 0.4f), + static (_, _, _, _) => { }), + new RemoteInboundMotionDispatcher( + static (_, _, _) => false, + static (_, _) => { }), + new LiveEntityMotionRuntimeController( + Runtime, + new PhysicsDataCache(), + static () => null, + new AcDream.Core.Selection.SelectionState(), + origin), + engine, + new NoopDatReaderWriter(), + new NoopAnimationLoader(), + combatTargetController: null, + origin, + new NoopTeleportSink(), + _nullClassification + ? new NoopLocalPlayerControllerSource() + : new StubLocalPlayerControllerSource(), + new LocalPlayerOutboundController(static (_, _, _, _, _, _) => { }), + new NoopPhysicsHostSource(), + identity, + new FixedScriptTime(), + new NoopSessionSource(), + publishTimestamps: static (_, _) => { }, + new NoopMovementTruthSink(), + acceptedPositionDrive, + remotePlacementDrive, + worldDropProjection: null); + } + + internal void PublishDestinationCollision() + { + var heights = new byte[81]; + Array.Fill(heights, (byte)SpawnHeight); + var heightTable = new float[256]; + for (int index = 0; index < heightTable.Length; index++) + heightTable[index] = index; + Lifetime.Physics.ObserveLocalWorldFrame( + SourceCell, teleportAdvanced: false); + Lifetime.Physics.SetPosition.BeginCollisionGeneration( + DestinationLandblock, 1UL); + Lifetime.Physics.Engine.AddLandblock( + DestinationLandblock, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: DestinationWorldOffset.X, + worldOffsetY: DestinationWorldOffset.Y); + Lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, 1UL, ready: true); + + uint destinationCanonical = CanonicalLandblock(DestinationLandblock); + if (!_spatial.IsLoaded(destinationCanonical)) + { + _spatial.AddLandblock(new LoadedLandblock( + destinationCanonical, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + } + } + + private static uint CanonicalLandblock(uint landblockId) => + (landblockId & 0xFFFF0000u) | 0xFFFFu; + + internal WorldSession.EntityPositionUpdate Update( + Vector3 destination, + uint cellId, + ushort teleportSequence, + uint guid, + bool isGrounded = true, + Vector3? velocity = null) => new( + guid, + new CreateObject.ServerPosition( + cellId, + destination.X, + destination.Y, + destination.Z, + 1f, 0f, 0f, 0f), + Velocity: velocity, + PlacementId: null, + IsGrounded: isGrounded, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: teleportSequence, + ForcePositionSequence: 0); + + private static DatReaderWriter.Types.MotionData MakeMotionData(uint animId) + { + var md = new DatReaderWriter.Types.MotionData(); + md.Anims.Add(new DatReaderWriter.Types.AnimData + { + AnimId = (QualifiedDataId)animId, + LowFrame = 0, + HighFrame = -1, + Framerate = 30f, + }); + return md; + } + + private static DatReaderWriter.DBObjs.Animation MakeTwoFrameAnim() + { + var anim = new DatReaderWriter.DBObjs.Animation(); + var pf0 = new DatReaderWriter.Types.AnimationFrame(1u); + var pf1 = new DatReaderWriter.Types.AnimationFrame(1u); + pf0.Frames.Add(new DatReaderWriter.Types.Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + pf1.Frames.Add(new DatReaderWriter.Types.Frame + { + Origin = Vector3.Zero, + Orientation = Quaternion.Identity, + }); + anim.PartFrames.Add(pf0); + anim.PartFrames.Add(pf1); + return anim; + } + + private sealed class FakeAnimationLoader : IAnimationLoader + { + private readonly Dictionary _anims = new(); + + internal void Register(uint id, DatReaderWriter.DBObjs.Animation anim) => + _anims[id] = anim; + + public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => + _anims.TryGetValue(id, out var a) ? a : null; + } + + internal EntityPhysicsHost ArmSticky(uint stickTargetGuid) + { + EntityPhysicsHost host = InstallHost(); + host.PositionManager.StickTo(stickTargetGuid, radius: 1f, height: 1f); + Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId()); + return host; + } + + internal EntityPhysicsHost InstallHost() + { + Assert.True(Runtime.TryGetRecord( + _guid, out LiveEntityRecord liveRecord)); + var host = new EntityPhysicsHost( + _guid, + getPosition: () => new AcDream.Core.Physics.Position( + Remote.CellId, Remote.Body.Position, Remote.Body.Orientation), + getVelocity: () => Remote.Body.Velocity, + getRadius: () => 0.48f, + inContact: () => Remote.Body.InContact, + minterpMaxSpeed: () => null, + curTime: () => 0d, + physicsTimerTime: () => 0d, + getObjectA: _ => null, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + Runtime.InstallPhysicsHost(liveRecord, host); + Remote.MarkFullPhysicsHostBound(); + return host; + } + + internal void DrainPlacementFifo() + { + while (Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token)) + break; + } + } + + public void Dispose() => Lifetime.Dispose(); + + internal sealed class RemoteServiceWindow : IRuntimeRemotePlacementServiceWindow + { + private readonly HashSet _within = []; + + internal void Allow(uint landblockId) => + _within.Add((landblockId & 0xFFFF0000u) | 0xFFFFu); + + public bool IsWithinServiceWindow(uint landblockId) => + _within.Contains((landblockId & 0xFFFF0000u) | 0xFFFFu); + } + + private sealed class NoopResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } + + private sealed class NoopCollisionSource : IPreparedCollisionSource + { + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + PreparedCollisionReadResult.Loaded( + new FlatSetupCollision( + System.Collections.Immutable + .ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() { } + } + + private sealed class NoopIdentitySource : ILocalPlayerIdentitySource + { + public uint ServerGuid => 0x50000099u; + } + + private sealed class NoopTeardownCoordinator + : ILiveEntityTeardownCoordinator + { + public void TearDown(LiveEntityRecord record) { } + public void ForgetUnknownOwner(uint serverGuid) { } + } + + private sealed class NoopMaterializer : ILiveEntityProjectionMaterializer + { + public bool TryMaterialize( + RuntimeEntityRecord expectedCanonical, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + AcDream.App.Rendering.LiveEntityAppearanceUpdateState? + appearanceUpdate = null) => + throw new InvalidOperationException( + "The fixture pre-materializes the remote entity; " + + "TryMaterialize should never be reached for an " + + "already-projected accepted Position."); + + public void ResetSessionState() { } + } + + private sealed class NoopRelationships : ILiveEntityRelationshipProjection + { + public void OnSpawn(WorldSession.EntitySpawn spawn) { } + public void OnParent(ParentEvent.Parsed update) { } + public void OnCreateParentAccepted(CreateParentUpdate update) { } + + public AcDream.App.Rendering.ChildUnparentDisposition + OnChildBecameUnparented(uint childGuid) => + AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; + + public bool TryApplyAttachedAppearance( + LiveEntityRecord record, ulong objDescAuthorityVersion) => false; + } + + private sealed class NoopReadyPublisher : ILiveEntityReadyPublisher + { + public bool Publish(LiveEntityReadyCandidate candidate) => true; + } + + private sealed class AlwaysKnownOrigin : ILiveEntityWorldOriginCoordinator + { + public bool IsKnown => true; + + public LiveEntityOriginInitialization TryInitialize( + WorldSession.EntitySpawn spawn) => new(true, []); + } + + private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink + { + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) { } + } + + private sealed class NoopTimestampPublisher + : IAcceptedLocalPhysicsTimestampPublisher + { + public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) { } + } + + private sealed class NoopDatReaderWriter : IDatReaderWriter + { + private readonly StubDatabase _portal = new(); + private readonly StubDatabase _highRes = new(); + private readonly StubDatabase _language = new(); + private readonly StubDatabase _cell = new(); + + public string SourceDirectory => string.Empty; + public IDatDatabase Portal => _portal; + public IDatDatabase Cell => _cell; + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => _highRes; + public IDatDatabase Language => _language; + public IDatDatabase Local => _language; + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes( + uint regionId, + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave( + uint regionId, + T obj, + int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => default; + + public bool TryGet( + uint fileId, + [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public void Dispose() { } + } + + private sealed class StubDatabase : IDatDatabase + { + public DatDatabase Db => throw new NotSupportedException(); + public int Iteration => 0; + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public bool TryGet( + uint fileId, + [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public bool TryGetFileBytes( + uint fileId, + [MaybeNullWhen(false)] out byte[] value) + { + value = null; + return false; + } + + public bool TryGetFileBytes( + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public void Dispose() { } + } + + private sealed class NoopAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } + + private sealed class NoopTeleportSink : ILocalPlayerTeleportNetworkSink + { + public void OnTeleportStarted(uint sequence) { } + + public void OfferDestination( + RuntimeTeleportDestination destination, + bool teleportTimestampAdvanced) + { } + + public void ResetSession() { } + + public void ResetGenerationPresentation() { } + } + + private sealed class StubLocalPlayerControllerSource + : IRuntimeLocalPlayerControllerSource + { + public PlayerMovementController? Controller { get; } = + new PlayerMovementController(new PhysicsEngine()); + } + + private sealed class NoopLocalPlayerControllerSource + : IRuntimeLocalPlayerControllerSource + { + public PlayerMovementController? Controller => null; + } + + private sealed class NoopPhysicsHostSource : ILocalPlayerPhysicsHostSource + { + public EntityPhysicsHost? Host => null; + } + + private sealed class FixedScriptTime : IPhysicsScriptTimeSource + { + public double CurrentScriptTime => 1_700_000_000d; + } + + private sealed class NoopSessionSource : ILiveWorldSessionSource + { + public WorldSession? CurrentSession => null; + } + + private sealed class NoopMovementTruthSink : IMovementTruthDiagnosticSink + { + public void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte) + { } + + public void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition) + { } + + public void ResetSession() { } + } + } +}