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() { } + } + } +}