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>
40 KiB
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— its 13 "must REMAIN true" invariants carry forward (§6 below).2026-08-04-c4-route-4b-3-retail-review.md/-round2.mdand2026-08-04-c4-route-4b-3-architecture-review.md/-round2.md— round 2's call-site-by-call-site verification ofRunRemoteArmTailandApplyWireAirborneLeftoverBookkeepingis the starting map and the method template for §5.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 —
ArmConstraintAfterOperationreadshost.Position, and the remote host'sgetPosition(LiveEntityMotionRuntimeController.cs:167-170) builds it fromhostRecord.FullCellId(committed by the shared prologue'sRebucketLiveEntity, or by the placement) — neverrm.CellId; ApplyInterpolate, the free-flight carve-out, andWillAdvanceRemoteMotiondo 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:
- 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.
- 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. - (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.
- 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. - 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):
- 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!isTeleportRoutegate; post-collapse one mechanism must serve both). - Landing packet (wire grounded, body not in contact): body snapped to
wire pose, entity synced, armed exactly once (the A1 assertion —
PositionManager.Constraintnull → 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. - 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). - Airborne no-op (
NoPositionOperation): AP-135 writes only, no arm. - Near interpolate (grounded, < 96 m, body in contact): enqueued (or AP-87-snapped per its three conditions), armed exactly once.
- Far snap (grounded, >= 96 m): placement executed, armed on every placement outcome, wire-cell adopt suppressed.
- 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. - 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:
- The pose still advances on every refusal — teleport/far placements
that never reach the engine still commit the accepted destination through
the
StoresAcceptedDestinationpartition;Deferred/RejectedByPlacementstill do not. (Runtime-side; this slice must simply not perturb the call.) - 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).
- The entity stays in-world on every outcome:
InWorld, clock active,FullCellId != 0, spatial projection intact. - 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.
- AP-135's two writes survive on every early-return path (the register row stays).
- The per-packet prologue runs for every classification: generic render
pose (gate
OwnsSteadyState, unchanged),RebucketLiveEntity, theTryCommitAuthoritativeVelocityinstall, currency re-validation chain. - 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).
- 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).
ApplyRemoteContactRouting'sAirborneNoOperationthrow stays unreachable — both early returns (now one) still precede routing.- The local-player paths are untouched: force-position block, F751
OfferDestinationtail, streaming observer, projectile short-circuit — everything above the remote section and theupdate.Guid == _playerServerGuidfilter 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.
OwnsPlacementkeeps excludingProjectileAuthoritative. - No edits inside
ApplyRemoteContactRouting, the classifier,RuntimeRemoteSteadyStatePosition,RuntimeRemoteFarSnapPosition,RuntimeRemotePlacementDriveController,RemoteTeleportHook, orRemoteServerControlledVelocityCycle— 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 atb260bcd1. 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 withACDREAM_PROBE_REMOTE_TELEPORT=1is 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:
- Production-line delta exceeds ~500, or the unified path needs a THIRD guid-conditional beyond row 8's named survivor.
- 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.
- 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.
- The §5 characterisation matrix FAILS against the current
b260bcd1code 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. - The complete Release suite deviates from baseline beyond the two named flakes.
11. Contradictions found while writing this contract — reported, not smoothed
- 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.
ApplyRemoteContactRouting's doc ("the player-remote caller reaches this method only withBody.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 theAirborneSnaparm 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.- The 4b-3 contract's baseline figure (11,027 at
2eb39a02) vs this task's (11,020 / 4 atb260bcd1) — not a true contradiction (different HEADs; route 6 and #314 landed between), but stated so nobody "corrects" one to the other. - 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. ApplyInterpolate's doc claims itsfirstUpevaluation "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.