acdream/docs/research/2026-08-04-onposition-collapse-contract.md
Erik edc911b042 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>
2026-08-04 18:07:54 +02:00

40 KiB
Raw Blame History

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:

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.InContactAirborneSnap, :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.