refactor(physics): collapse OnPosition's dual player/NPC remote tail into one

C4 route 4b-3 collapse (docs/research/2026-08-04-onposition-collapse-contract.md).
Behaviour-preserving: the ~640-line duplicated player-guid and NPC-guid
copies of the remote routing tail in LiveEntityNetworkUpdateController.OnPosition
become one guid-blind tail, reached by every remote guid through the single
ApplyRemoteContactRouting/RunRemoteArmTail seam.

Two guid-conditionals survive, both named and justified:
- Row 8 (TS-44 sticky suppression, creature-only): retail's sticky is
  independent of this acdream-only steady-state gate; the register row
  already describes it as NPC-only and this collapse does not widen it.
- The AirborneSnap arm's interp-clear + shadow-publish (rows 2a/2b,
  player-only preserve): unifying either way would be an unauthorized
  behaviour change. #316 (shadow publish) is a real, unmeasured
  pre-existing defect, deliberately preserved not fixed. The interp-clear's
  equivalence could not be proven for the steep-non-walkable-landing edge
  case (AdjustOffset's CONTACT-keyed gate vs. AP-139's WALKABLE-keyed
  per-tick clear) — preserved per contract stop condition 2 rather than
  shipped on an incomplete proof.

Category-(c) resolutions (contract §2.1-2.5), each with its evidence:
- Row 2a (interp clear): PRESERVED — AdjustOffset's `if (!inContact) return`
  proves inertness on flat landings, but not on the steep-contact edge case.
- Row 2b (shadow publish / #316): PRESERVED — no design note ever sanctioned
  the player-guid skip; the file's own #184 Slice 2b comments contradict it.
- Row 2c (EnsureRemoteMotionBindings): UNIFIED — the method is idempotent
  (`if (rm.Host is not null) return rm.Sink;`), so "always ensure" is safe.
- Row 3 (wire-cell adopt ordering): UNIFIED — RebucketLiveEntity already
  commits the wire cell before either guid branch runs, so the deleted
  player-guid pre-write was a proven no-op.
- Row 4 (LastServerPos/Time sample timing): UNIFIED — on a genuine first UP,
  InterpolationManager.Enqueue's already-close branch and the Snapped branch
  both converge on the same body pose/orientation for a zero-distance target.
- Row 12 (wall-clock capture): UNIFIED — one shared `nowSec`, a
  microsecond-scale skew in acdream-only bookkeeping/diagnostics.

Sabotage check (contract §5, performed and reverted, not committed):
deleting the one remaining TryArmConstraintAfterOperation call failed
10/16 dual-guid matrix tests, spanning BOTH guid halves of every
arming-dependent scenario (teleport, landing, near, far, sticky) — proof
the matrix discriminates a defect regardless of which guid range exercises
it, closing the class of bug that let 4b-3's A1/A2/R3 findings survive
review when only one copy's tests were green.

New tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
drives 8 scenarios x 2 guid ranges (0x50xxxxxx player, 0x8xxxxxxx creature)
through the complete production OnPosition entry point. Doc comments on
ApplyRemoteContactRouting, RunRemoteArmTail, ApplyWireAirborneLeftoverBookkeeping,
TryAdoptWireCellAfterRouting, and the AirborneNoOperation throw guard
updated to describe the collapsed one-path world (the "two callers stay
one decision" claim was true before this commit and false after — fixed
in the same commit that makes it false). One branch-routing source-text
pin (LiveEntityNetworkBranchRoutingTests.cs) updated to follow the AP-140
CONTACT gate to its new address inside ApplyRemoteContactRouting.

#316 stays OPEN, deliberately not fixed here — see its updated ISSUES.md
entry.

dotnet build AcDream.slnx -c Release: 0 errors. Verified independently
bisectable at this exact commit: AcDream.App.Tests 4104/4107 (3 pre-existing
skips), AcDream.Runtime.Tests 1125/1125.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 18:07:54 +02:00
parent a89bcb39b2
commit edc911b042
5 changed files with 2225 additions and 641 deletions

View file

@ -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 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 refactor, and its severity is unmeasured. Split-on-discovery: own commit, with
a dual-guid test, after the measurement. 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`.

View file

@ -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 — <justification>`, `(b) unified — <proof>`, or
`(c) resolved to (a)/(b) — <evidence>`. 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<bool>` closure allocation at the three
`RunRemoteArmTail` call sites; its root cause is
`ApplyRemoteContactRouting`'s `Func<bool>` 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<bool>` 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.

File diff suppressed because it is too large Load diff

View file

@ -120,9 +120,9 @@ public sealed class LiveEntityNetworkBranchRoutingTests
} }
/// <summary> /// <summary>
/// AP-140 (retired 2026-08-04): the SECOND accepted-Position routing /// AP-140 (retired 2026-08-04): the accepted-Position routing gate
/// gate — <c>OnPosition</c>'s player-remote landing block — must /// for the free-flight/landing decision must select the hard snap on
/// select the hard snap on retail's CONTACT predicate /// retail's CONTACT predicate
/// (<c>InterpolationManager::adjust_offset</c> @0x00555D30 gates its /// (<c>InterpolationManager::adjust_offset</c> @0x00555D30 gates its
/// whole body on <c>transient_state &amp; 1</c> @0x00555D52, and bit 0 /// whole body on <c>transient_state &amp; 1</c> @0x00555D52, and bit 0
/// is <c>CONTACT_TS</c>), not on the client <c>Airborne</c> flag, /// is <c>CONTACT_TS</c>), not on the client <c>Airborne</c> flag,
@ -130,13 +130,23 @@ public sealed class LiveEntityNetworkBranchRoutingTests
/// set that also captures a remote sliding on a steep face. /// set that also captures a remote sliding on a steep face.
/// ///
/// <para> /// <para>
/// C4 route 4b-3's OnPosition collapse (2026-08-04) dissolved
/// <c>OnPosition</c>'s former standalone player-remote LANDING
/// TRANSITION block — which used to carry its own
/// <c>if (!rmState.Body.InContact)</c> copy of this gate — into
/// <c>ApplyRemoteContactRouting</c>'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.
/// </para>
///
/// <para>
/// A source pin rather than a behavioural fixture for the reason this /// A source pin rather than a behavioural fixture for the reason this
/// class already documents: the controller's dependency set is /// class already documents: the controller's dependency set is
/// composition-only. The twin gate inside /// composition-only. The gate inside <c>ApplyRemoteContactRouting</c>
/// <c>ApplyRemoteContactRouting</c> — a static method, so reachable — /// — a static method, so reachable — IS covered behaviourally, in
/// IS covered behaviourally, in
/// <c>LiveEntityNetworkRemoteSteadyStateIntegrationTests</c>. Restore /// <c>LiveEntityNetworkRemoteSteadyStateIntegrationTests</c>. Restore
/// <c>if (rmState.Airborne)</c> here and this test fails. /// <c>if (rmState.Airborne)</c> or <c>if (remote.Airborne)</c> here
/// and this test fails.
/// </para> /// </para>
/// </summary> /// </summary>
[Fact] [Fact]
@ -145,7 +155,7 @@ public sealed class LiveEntityNetworkBranchRoutingTests
string source = ReadSource("LiveEntityNetworkUpdateController.cs"); string source = ReadSource("LiveEntityNetworkUpdateController.cs");
Assert.Contains( Assert.Contains(
"if (!rmState.Body.InContact)", "if (!remote.Body.InContact)",
source, source,
StringComparison.Ordinal); StringComparison.Ordinal);
// `rmState.Airborne` survives as a WRITE target and in prose (the // `rmState.Airborne` survives as a WRITE target and in prose (the

File diff suppressed because it is too large Load diff