fix(runtime): close the C5b re-review findings — Gate A narrowing filed, no-window payload gate, bisect hazard recorded

Both C5b re-reviews returned PASS on 02578441..ff100cf3. This lands the
bookkeeping corrections they left, the one gate asymmetry both found
independently, and one wrong retail fact neither of them caught.

1. AP-148 / #325 — Gate A's teleport test, wrong on primary source twice.

The C5b contract stated retail's Gate A teleport term as "TELEPORT_TS
equal" (and, in the trace block, as "must NOT be newer") and blessed
acdream's `teleport == _timestamps[Teleport]` as retail-exact. Disassembly
of the PDB-paired binary at SmartBox::HandleReceivedPosition
0x0045402B-0x00454054 says otherwise: the shortcut is taken iff the wire
stamp is equal OR newer (wrap-safe) — `sbb eax,eax / neg eax` materialises
the carry of the compare and the branch skips Gate A on CF, i.e. only when
the wire stamp is strictly OLDER. It is CPhysicsObj::newer_event
@0x00451B10's identical idiom with the operands swapped. Binary Ninja drops
the flag test and renders it `if (-((eax_7 - eax_7)) == 0)`, always true —
which is why two rounds of reading pseudo-C recorded it backwards.

So acdream's ForcePosition disposition is a strict SUBSET of retail's Gate
A set, and a local ForcePosition carrying a newer teleport stamp is
misrouted into a full Apply: wire heading instead of preserved heading,
unparent, possible placement frame, zeroed velocity, TELEPORT_TS advanced,
and OfferTeleportDestination called for a packet retail never starts
presentation for.

PhysicsTimestampGate.cs is NOT changed. The predicate exists twice (also
ValidAcceptedAuthority's PreviousTeleport == AcceptedTeleport), and the fix
has to decide TELEPORT_TS's disposition on a Gate A path that has never
seen a stale-but-equal pair. #325 records all of it and says explicitly
that it is not a one-line comparison swap. C5b made this marginally
better, not worse: clearParent was unconditionally true before C5b and is
unchanged; installPlacementFrame moved toward retail's HasAnims gate.

2. Retail F2 / architecture L-A — the no-window route had no pre-merge
payload validation. Root fix, not a documented asymmetry.

The graphical route validates before the merge (OnPosition's payloadIsValid
-> LiveEntityInboundAuthorityGate's !payloadIsValid return); despite its
name CanAcceptPositionPayload is not projectile-scoped. The no-window route
had no equivalent, and since D1 fed an unvalidated LandblockId into
CommitWireCellRebucket — where 0 is the withdrawal shape, silently
de-residencing the entity in the field every bot reads as CellId.

RuntimeLiveEntitySessionController.OnPositionUpdated now applies the same
rule at the same point, reusing
RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition plus
the finite-velocity term — the exact pair TryApplyPosition already applies
on its initial-residence branch. Chosen over documenting it because the fix
is five lines and leaving it would have left two written claims falsified
by the code. It is a behaviour change: headless now drops packets it
merged. Against ACE the set is empty, and the graphical host has carried
this gate since it was written; the argument is recorded in the contract's
§15.2 rather than gated.

Two test fixtures carried cell ids retail's own inbound_valid_cellid
rejects (low words 0x41 and 0x51, above the 0x40 landcell ceiling). Their
constants were corrected; their assertions were not.

New test sabotage-verified in both directions: gate removed -> red at the
withdrawal-shape assertion; gate moved to guard only the cell commit ->
red at the pose assertion, which is what makes it a before-the-MERGE test
rather than a before-the-commit test.

3. Register and doc corrections.

- AD-64: "deliberately absent" was presented as the complete difference
  list and was not. Adds (a) the residence gate is weaker than the merge's
  own — both hosts' commits use TryGetCurrent while TryApplyPosition's FIFO
  branch uses TryGetTransaction, so the wire cell can commit ahead of the
  continuation that will replay it; (b) the two missile gates are two
  different expressions that agree today; (c) the payload gate, now
  present. Risk column records that (a) and (b) have no discriminating test
  on either side.
- AP-147: amended for D1 — pre-D1 the no-window host published [Updated]
  alone and lost the Rebucketed, so a headless event log is now a real
  instance of the "consumer that snapshots a delta" the row warns about.
- AD-60: "Matches retail exactly" scoped to the withhold, since the row's
  body documents two channels that do not.
- CommitWireCellRebucket: notes the unreachable ThrowIfNull /
  EnsureNotDisposed precedence inversion.
- TryCommitAcceptedWireCell: the discarded commit bool is explained rather
  than left bare — false means IsCurrent went stale, unreachable three
  statements after a synchronous TryGetActive.

4. Bisect hazard recorded in the C4 closeout handoff (the doc CLAUDE.md
sends readers to before any C5 work) and in the contract's §15.3: commits
735f0a72..23aa62f2 contain a live headless defect — every remote's
FullCellId frozen for the session — introduced by 735f0a72 and fixed only
at ff100cf3. Nothing throws and no test in the range fails.

Gates: Release build 0 errors/0 warnings. Complete suite 11,142 passed /
4 skipped / 0 failed against the 11,141 / 4 / 0 baseline — net +1, exactly
the one new test. No flake appeared (#302, #308, #321 all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-05 23:10:24 +02:00
parent ff100cf33f
commit 9ee9c1a1a6
7 changed files with 455 additions and 21 deletions

View file

@ -24,6 +24,84 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #325 — Gate A's teleport test is narrower than retail's: a ForcePosition carrying a NEWER teleport stamp is misrouted into a full Apply
**Status:** OPEN
**Severity:** MEDIUM (no observed symptom; reachability against ACE is
unmeasured — see below. The behaviour when reached is four simultaneous
divergences, not one.)
**Filed:** 2026-08-05 at the C5b closeout, with register row **AP-148**
**Component:** physics / inbound timestamp gate / local-player force position
**Description.** Retail's `SmartBox::HandleReceivedPosition` Gate A — the
local-player FORCE_POSITION self-echo shortcut — takes its shortcut iff the
wire TELEPORT_TS is **not older** than the stored one, i.e. equal *or newer*.
acdream requires exact equality
(`PhysicsTimestampGate.TryAcceptPositionEvent:199`,
`teleport == _timestamps[Teleport]`), so acdream's `ForcePosition`
disposition is a strict subset of retail's Gate A set.
The disassembly, the byte-level reasoning, and why two review rounds of
reading Binary Ninja pseudo-C recorded the term backwards are in AP-148 and
in `docs/research/2026-08-05-c5b-contract.md` §15.1. Short version:
0x0045402B0x00454054 materialises the carry of a wrap-safe 16-bit compare
with `sbb eax,eax / neg eax` and skips Gate A on CF, where CF means "wire
strictly older"; Binary Ninja drops the flag test and renders the whole
thing as `if (-((eax_7 - eax_7)) == 0)`, which is always true.
**What the misroute does.** The excluded packet falls through to `Apply`
with `advancesTeleport` true, which is four behaviour changes at once:
1. the wire heading is applied instead of the body's being preserved
(`InboundPhysicsStateController.ApplyAcceptedPosition:846-856` is
`ForcePosition`-gated);
2. the entity is unparented, and may have a placement frame installed
(`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations`);
3. local velocity is zeroed (`:882-885`, the `TeleportAdvanced` arm);
4. TELEPORT_TS advances and `OfferTeleportDestination` is called, starting
teleport/portal presentation for a packet retail never starts it for.
Retail's Gate A deliberately lets a force ride *past* a pending teleport
advance without consuming it — it returns @0x0045409D, before
`newer_event(arg2, TELEPORT_TS, arg8)` @0x00454158 — leaving the ordinary
Position channel to process that teleport.
**This is NOT a one-line comparison swap, and the fix must not be attempted
as one.** Three things have to be decided together:
1. **The predicate exists twice.** Besides `PhysicsTimestampGate.cs:199`,
`RuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthority`
independently requires
`authority.PreviousTeleportSequence == authority.AcceptedTeleportSequence`
for a `ForcePosition` — the same narrowing, encoded downstream. Widening
one without the other turns the newly-admitted packets into
`RejectedAuthority` routes, which is a third behaviour, worse than either.
2. **TELEPORT_TS's disposition on the Gate A path.** acdream's Gate A branch
already returns without advancing TELEPORT_TS, which matches retail —
but it has never had to do so while the wire stamp was *newer*. After
widening, `AcceptedPhysicsTimestamps.PreviousTeleport` and `.Teleport`
would be equal and STALE while the wire carried a newer value: a shape no
consumer has seen. `IsFreshTeleportStart`, the drive controller's
`previousTeleport` argument, and J6.3's F751/Position teleport
correlation all read that pair.
3. **Reachability has to be established before the fix, not assumed.** ACE's
two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement,
`Player_Tick.cs:488` z-hack correction) do not themselves bump the
teleport sequence — but `PositionPack` serialises the *current* teleport
sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent
window on its next force. Whether that lag is reachable in practice is
unmeasured. A cdb trace or a wire capture answers it; guessing does not.
The correct predicate already exists verbatim one file away:
`PhysicsTimestampGate.IsFreshTeleportStart:163` is
`!IsNewer(teleport, _timestamps[Teleport])`, which is exactly retail's Gate A
term.
**Acceptance:** both encodings widened together; the newly-admitted shape
covered by a discriminating test at the disposition boundary AND at the
classifier's authority validation; the three consumers in item 2 checked
against a stale-but-equal teleport pair; AP-148 retired in the same commit.
## #324 — The graphical and no-window hosts run parallel, non-shared inbound entity routes ## #324 — The graphical and no-window hosts run parallel, non-shared inbound entity routes
**Status:** OPEN **Status:** OPEN

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,27 @@
Written at C4's implementation closeout. **Read this before touching anything.** Written at C4's implementation closeout. **Read this before touching anything.**
> ## ⚠ BISECT HAZARD — commits `735f0a72..23aa62f2`
>
> Added 2026-08-05 at the C5b closeout. **A `git bisect` that lands anywhere
> in that three-commit range will hit a live, unrelated headless defect.**
> `735f0a72` (C5b) made the steady-state Position merge withhold the wire
> cell and relied on a replacement writer that lives in `AcDream.App`; the
> no-window host has no analogue, so across that range **every remote
> entity's `FullCellId` is frozen at its placement value for the whole
> session** in `AcDream.Headless`, and the local player loses one of
> AP-146's three cell-refresh edges. Nothing throws; no test in the range
> fails. A bot's `RuntimeEntitySnapshot.CellId` simply stops advancing and
> `RuntimeSetPositionState.IsAffectedCollisionResident` parks bodies against
> a landblock they have left. Fixed at `ff100cf3`. The range is exactly
> `735f0a72`, `ed806997`, `23aa62f2`.
>
> If you are bisecting a headless cell/residency symptom, treat any `bad`
> verdict inside that range as suspect and re-test with `ff100cf3`'s
> `RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` cherry-picked
> on top. Structural cause: issue **#324**. Residual duplication: **AD-64**.
> Full write-up: `docs/research/2026-08-05-c5b-contract.md` §15.3.
## Where the branch is ## Where the branch is
- Worktree `C:\Users\erikn\source\repos\acdream\.claude\worktrees\peaceful-visvesvaraya-e0a196` - Worktree `C:\Users\erikn\source\repos\acdream\.claude\worktrees\peaceful-visvesvaraya-e0a196`

View file

@ -38,7 +38,10 @@ shape, and the (test-only) missile arm. The contract pins all of this.
00453fe3 objcell_id = arg3->objcell_id // wire cell read into a LOCAL 00453fe3 objcell_id = arg3->objcell_id // wire cell read into a LOCAL
00453ff4 Frame::operator=(&var_40, &arg3->frame) // wire frame copied into local Position var_48 00453ff4 Frame::operator=(&var_40, &arg3->frame) // wire frame copied into local Position var_48
0045400c GATE A: if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9)): 0045400c GATE A: if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9)):
00454044 wrapped-compare update_times[4] (TELEPORT_TS) vs arg8 — teleport must NOT be newer 0045402b-54 wrapped-compare update_times[4] (TELEPORT_TS) vs arg8 — the wire
teleport stamp must NOT be OLDER (equal or newer both pass).
CORRECTED 2026-08-05, see §15 — this line read "must NOT be
newer" and it was backwards.
00454056-68 get_heading / Frame::set_heading(&var_40) // preserve body heading 00454056-68 get_heading / Frame::set_heading(&var_40) // preserve body heading
00454074 SmartBox::BlipPlayer(this, &var_48) 00454074 SmartBox::BlipPlayer(this, &var_48)
00454079 player->update_times[0] = arg7 // stamp POSITION_TS 00454079 player->update_times[0] = arg7 // stamp POSITION_TS
@ -70,16 +73,24 @@ Five facts decide C5b:
rule AD-60's executor half already encodes ("a wire position never rule AD-60's executor half already encodes ("a wire position never
directly makes the record resident"). directly makes the record resident").
2. **Gate A (@0x0045400C) is decided on data that exists before any merge:** 2. **Gate A (@0x0045400C) is decided on data that exists before any merge:**
the entity is the player, FORCE_POSITION_TS advanced, TELEPORT_TS equal. the entity is the player, FORCE_POSITION_TS advanced, and the wire
It returns @0x0045409D **before `unset_parent` @0x00454129 and before the TELEPORT_TS is **not older** than the stored one. It returns @0x0045409D
`SetPlacementFrame` gate @0x00454137** — a ForcePosition never unparents **before `unset_parent` @0x00454129 and before the `SetPlacementFrame`
and never installs a placement frame. acdream's exact analog exists gate @0x00454137** — a ForcePosition never unparents and never installs
upstream: `PhysicsTimestampGate.TryAcceptPositionEvent:190-203` produces a placement frame. acdream's analog exists upstream:
the `ForcePosition` disposition only when `isLocalPlayer && `PhysicsTimestampGate.TryAcceptPositionEvent:190-203` produces the
IsNewer(FORCE_POSITION_TS) && teleport == _timestamps[Teleport]` — `ForcePosition` disposition when `isLocalPlayer &&
retail's Gate A pair, verified at HEAD. **A remote entity can never IsNewer(FORCE_POSITION_TS) && teleport == _timestamps[Teleport]`.
receive the `ForcePosition` disposition** (`isLocalPlayer` guard at **CORRECTED 2026-08-05 (see §15): that third term is retail's Gate A pair
`:190`), so the flag truth table below needs no entity-kind term. NARROWED, not matched.** Retail's test is `teleport` equal-or-newer;
acdream's is equal only. The narrowing is filed at **AP-148 / issue
#325**. Everything C5b decides from Gate A is unaffected — the
disposition C5b reads is the same one it always was, and the narrowing
makes the `ForcePosition` set strictly SMALLER, so no packet C5b's truth
table classifies as force would have been classified otherwise.
**A remote entity can never receive the `ForcePosition` disposition**
(`isLocalPlayer` guard at `:190`), so the flag truth table below needs no
entity-kind term.
3. **The two pre-placement writes are gated on exactly two facts.** 3. **The two pre-placement writes are gated on exactly two facts.**
`unset_parent` @0x00454129 runs for every accepted non-Gate-A Position `unset_parent` @0x00454129 runs for every accepted non-Gate-A Position
(including packets `MoveOrTeleport` will return 0 for — retail unparents (including packets `MoveOrTeleport` will return 0 for — retail unparents
@ -337,7 +348,12 @@ For every path this slice touches, including every refusal and rejection:
stays upstream (`PhysicsTimestampGate.cs:190-217`; a force with a stays upstream (`PhysicsTimestampGate.cs:190-217`; a force with a
teleport advance falls through to `Apply`, and teleport advance falls through to `Apply`, and
`ValidAcceptedAuthority`'s `PreviousTeleport == AcceptedTeleport` rule `ValidAcceptedAuthority`'s `PreviousTeleport == AcceptedTeleport` rule
handles the classified side). handles the classified side). **CORRECTED 2026-08-05 (§15): that
fallthrough is not retail's shape.** Retail's Gate A accepts a force
carrying a NEWER teleport stamp and returns before ever advancing
TELEPORT_TS. Both acdream sites named in this item encode the narrower
equality; both are filed at AP-148 / #325, and both stay untouched by
C5b — closing them is its own slice with its own gate.
7. **The OnPosition routing/arms are untouched** — generic render-pose 7. **The OnPosition routing/arms are untouched** — generic render-pose
gate, W2, the arm dispatch, W3, the unified tail, AP-87's catch-up, gate, W2, the arm dispatch, W3, the unified tail, AP-87's catch-up,
AP-139's landing clear, AP-140's `InContact` gates. AP-139's landing clear, AP-140's `InContact` gates.
@ -779,3 +795,124 @@ dropped item at W2 rests on the code reading above plus the two hydration
tests now driving recovery-then-W2 in production order. §9's connected gate tests now driving recovery-then-W2 in production order. §9's connected gate
recipe item 1 (pickup-then-drop, several times) is the positive evidence for recipe item 1 (pickup-then-drop, several times) is the positive evidence for
it and has not been run. it and has not been run.
---
## 15. Corrections and hazards found at the C5b closeout (2026-08-05)
Both C5b re-reviews (retail-conformance and architecture) returned PASS on
`02578441..ff100cf3`. This section carries what they left behind, plus one
correction neither of them made and both of them missed twice.
### 15.1 §1's Gate A teleport test was WRONG on primary source
This document's §1 stated retail's Gate A teleport term two ways, and both
were wrong: the trace line read "teleport must NOT be newer", and fact 2
read "TELEPORT_TS equal". It then blessed acdream's
`teleport == _timestamps[Teleport]` (`PhysicsTimestampGate.cs:199`) as
"retail's Gate A pair, verified at HEAD".
**Verified against the PDB-paired binary** (`acclient.exe` v11.4186,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`, `check_exe_pdb.py`
reports MATCH), disassembled with capstone at
`SmartBox::HandleReceivedPosition` 0x0045402B0x00454054:
```
00453fff mov esi, [esp+0x68] ; arg2 (the object)
00454003 cmp esi, ecx ; == this->player ?
00454005 mov ebx, [esp+0x80] ; ebx = arg8 = WIRE teleport stamp
0045401c call 0x451b10 ; newer_event(player, 6=FORCE_POSITION_TS, arg9)
0045402b mov bp, word [ecx+0x16c] ; bp = player->update_times[4] == TELEPORT_TS
; (base 0x164, 2 bytes/entry -> index 4)
00454032 movzx edx, bx ; wire
00454035 movzx eax, bp ; stored
00454038 sub eax, edx / cdq / xor eax,edx / sub eax,edx ; abs(stored - wire)
0045403f cmp eax, 0x7fff
00454044 jg 0x45404b ; far apart -> wrapped compare
00454046 cmp bx, bp ; unwrapped: CF <=> wire < stored
00454049 jmp 0x45404e
0045404b cmp bp, bx ; wrapped: CF <=> stored < wire
0045404e sbb eax, eax / neg eax ; eax = CF
00454052 test eax, eax
00454054 jne 0x4540a0 ; CF set (wire strictly OLDER) -> SKIP Gate A
```
The shortcut is taken **iff the wire teleport stamp is equal or newer**
(wrap-safe) — "not older". It is `CPhysicsObj::newer_event` @0x00451B10's
identical idiom with the two compare operands swapped: `newer_event`'s
unwrapped compare is `cmp si(stored), di(incoming)` and returns 1 on CF,
i.e. "incoming is newer"; Gate A's is `cmp bx(wire), bp(stored)` and skips
on CF, i.e. "wire is older". `acclient.h:6090` confirms
`update_times[4] == TELEPORT_TS`, and 0x00454084's
`mov word [edx+0x164], ax` confirms the array base via the POSITION_TS
stamp.
**Why two review rounds missed it.** Binary Ninja drops the flag test and
renders the whole sequence as `if (-((eax_7 - eax_7)) == 0)` — vacuously
true. Reading the pseudo-C, at any level of care, cannot recover this.
Reading the bytes takes five minutes. This is the same class as the PE
byte-decode finding in `claude-memory/reference_pe_byte_decode.md`: when a
decomp renders a comparison as a tautology, that is a decompiler artifact
signature, not a retail fact.
**Consequence.** acdream's `ForcePosition` disposition is a strict SUBSET
of retail's Gate A set. Filed as **AP-148** with issue **#325**. Not fixed
here: see the AP row and the issue for why it is not a one-line comparison
swap.
**C5b's own effect on this row is marginally POSITIVE, not negative.**
`clearParent` was unconditionally `true` before C5b and is `!force` after —
unchanged for the misrouted `Apply`. `installPlacementFrame` went
unconditional `true``!force && !hasAnimations`, i.e. toward retail's
"Gate A never reaches `SetPlacementFrame`" for the animated half. C5b
neither introduced nor widened the narrowing; it narrowed the damage.
### 15.2 The no-window route had no pre-merge payload validation
Retail finding F2 / architecture finding L-A, found independently by both
re-reviewers. Fixed in the closeout commit rather than documented: the
no-window route (`RuntimeLiveEntitySessionController.OnPositionUpdated`)
now applies the same predicate at the same point as the graphical route's
`payloadIsValid` gate. See AD-64 and the method's own comment. The choice
was the root fix rather than a documented asymmetry because the fix is five
lines, reuses an existing Runtime predicate verbatim
(`RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition`
plus the finite-velocity term — the exact pair
`RuntimeEntityObjectLifetime.TryApplyPosition` already applies on its
initial-residence branch), and leaving it would have left two written
claims falsified by the code (the `TryCommitAcceptedWireCell` doc's "under
the same reachability rules the graphical `OnPosition` route applies" and
AD-64's "whose gates were derived from those returns one by one").
**It is a behaviour change, and its blast radius is bounded by argument,
not by a gate.** Headless now drops packets it previously merged. Against
ACE the set is empty: ACE resolves cell 0 at `Position` construction and
never serializes a NaN/Inf frame, and `PositionPack` writes a real
`ObjCellId`. The shape is unreachable in the same way it has always been
unreachable for the graphical host, which has carried this gate since it
was written. Two test fixtures did carry illegal cell ids (low word `0x41`
and `0x51`, both above `LandDefs.CellLowInRange`'s `0x40` landcell
ceiling); their constants were corrected, their assertions were not.
### 15.3 BISECT HAZARD — `735f0a72..23aa62f2` contain a live headless defect
**A `git bisect` that lands anywhere in this range will hit a real,
unrelated defect.** `735f0a72` (the C5b implementation) made the
steady-state Position merge withhold the wire cell, and the replacement
writer it relied on — the `OnPosition` prologue rebucket — lives in
`AcDream.App`. The no-window host has no analogue, so across that range
**every remote entity's `FullCellId` is frozen at its placement value for
the whole session** in `AcDream.Headless`, and the local player loses one
of AP-146's three cell-refresh edges. Nothing throws and no test in that
range fails; a bot's `RuntimeEntitySnapshot.CellId` simply stops advancing,
and `RuntimeSetPositionState.IsAffectedCollisionResident` parks bodies
against a landblock they have left.
Fixed at `ff100cf3` (the D1 fix). The range is three commits:
`735f0a72`, `ed806997`, `23aa62f2`.
If you are bisecting a headless cell/residency symptom, treat any `bad`
verdict inside that range as suspect and re-test with `ff100cf3`'s
`RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` cherry-picked
on top. Structural cause: issue **#324** (two parallel, non-shared inbound
routes); residual duplication: **AD-64**.

View file

@ -2092,6 +2092,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
uint spatialCellOrLandblockId, uint spatialCellOrLandblockId,
Action<RuntimeEntityRecord>? acknowledgeProjection = null) Action<RuntimeEntityRecord>? acknowledgeProjection = null)
{ {
// Noted at the C5b closeout, unreachable in production and left as
// is: this null check runs BEFORE CommitRebucket's EnsureNotDisposed,
// so a call that is both disposed AND null throws
// ArgumentNullException where the same call straight to
// CommitRebucket throws ObjectDisposedException. No caller can
// produce that pair — both hosts' callers resolve `canonical` from a
// live TryGetActive on the same thread — and reordering would put a
// disposed-instance check in front of a derivation that touches no
// instance state.
ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(canonical);
uint committedFullCell = uint committedFullCell =
(spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu

View file

@ -4,6 +4,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.World; using AcDream.Runtime.World;
namespace AcDream.Runtime.Session; namespace AcDream.Runtime.Session;
@ -223,6 +224,42 @@ public sealed class RuntimeLiveEntitySessionController
private void OnPositionUpdated( private void OnPositionUpdated(
WorldSession.EntityPositionUpdate update) WorldSession.EntityPositionUpdate update)
{ {
// C5b follow-up (2026-08-05), retail finding F2 / architecture
// finding L-A, found independently by both re-reviewers. The
// graphical route validates the wire payload BEFORE the merge —
// LiveEntityNetworkUpdateController.OnPosition computes
// `payloadIsValid` from ProjectileController.CanAcceptPositionPayload
// (retail Position::IsValid @0x005A9480 composed with Frame::IsValid
// @0x00534ED0, plus finite origin/velocity) and
// LiveEntityInboundAuthorityGate.TryAcceptPosition returns false on
// it, ahead of the timestamp gate and every wire-cell writer. Despite
// its name that check is not projectile-scoped; it runs for every
// guid. This route had no equivalent, so an invalid payload merged
// here and then — since D1 — fed its unvalidated LandblockId into
// CommitWireCellRebucket, whose own doc calls a 0 landblock "the
// withdrawal shape": cell 0 + landblock 0, silently de-residencing
// the entity in the exact field every bot reads as
// RuntimeEntitySnapshot.CellId.
//
// The predicate is not re-derived here. It is
// RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition
// plus the finite-velocity term — literally the pair
// RuntimeEntityObjectLifetime.TryApplyPosition already applies on its
// initial-residence branch, and the same composition the graphical
// gate applies. Rejecting BEFORE the merge (rather than before the
// cell commit alone) is what makes the two hosts genuinely
// symmetric: neither one lets an invalid payload advance the
// timestamp gate.
if (!RuntimeAuthoritativePositionRouteClassifier
.IsValidCreateWirePosition(update.Position)
|| update.Velocity is { } wireVelocity
&& !(float.IsFinite(wireVelocity.X)
&& float.IsFinite(wireVelocity.Y)
&& float.IsFinite(wireVelocity.Z)))
{
return;
}
bool isLocal = bool isLocal =
update.Guid == _runtime.PlayerIdentity.ServerGuid; update.Guid == _runtime.PlayerIdentity.ServerGuid;
PlayerMovementController? localController = PlayerMovementController? localController =
@ -358,9 +395,12 @@ public sealed class RuntimeLiveEntitySessionController
/// <summary> /// <summary>
/// D1 (C5b architecture review): commits the accepted wire cell to /// D1 (C5b architecture review): commits the accepted wire cell to
/// canonical residency for a no-window host, under the same /// canonical residency for a no-window host, under reachability rules
/// reachability rules the graphical <c>OnPosition</c> route applies to /// derived one by one from the graphical <c>OnPosition</c> route's own
/// AD-60's W2. The committed VALUE is /// early returns for AD-60's W2. They are NOT identical, and AD-64
/// enumerates every place they differ — the two absent gates, the
/// residence gate's weaker predicate, and the missile gate's structural
/// drift risk. The committed VALUE is
/// <see cref="RuntimeEntityObjectLifetime.CommitWireCellRebucket"/>'s — /// <see cref="RuntimeEntityObjectLifetime.CommitWireCellRebucket"/>'s —
/// one rule, shared by both hosts, including its landblock-vs-cell /// one rule, shared by both hosts, including its landblock-vs-cell
/// branch. /// branch.
@ -387,6 +427,27 @@ public sealed class RuntimeLiveEntitySessionController
/// operation kind. Committing a wire cell for a projectile here would /// operation kind. Committing a wire cell for a projectile here would
/// invent residency a placement route owns. /// invent residency a placement route owns.
/// </para> /// </para>
///
/// <para>
/// <b>Two known imprecisions, both host-symmetric and both pre-existing;
/// filed at AD-64 rather than papered over here.</b> (1) The residence
/// gate uses <c>TryGetInitialCreateResidence</c> (<c>TryGetCurrent</c>),
/// while <c>RuntimeEntityObjectLifetime.TryApplyPosition</c>'s own FIFO
/// branch uses the strictly WEAKER <c>TryGetPendingInitialResidence</c>
/// (<c>TryGetTransaction</c> = current OR a completed-but-unretired
/// lease). In that window the merge enqueues the packet as a
/// continuation while this gate reads "no residence" and commits the
/// wire cell ahead of the continuation that will replay it. The
/// graphical route's <c>RebucketLiveEntity</c> reads the same weaker
/// predicate, so both hosts have it identically. (2) The missile
/// predicate below is the graphical route's FALLBACK conjunction; that
/// route PREFERS <c>earlyRemoteRoute.OperationKind is
/// ProjectileAuthoritative</c> and drops to the conjunction only when
/// the classification is null. The two agree today — the conjunction is
/// what the classifier's own projectile test is built from — but they
/// are separate expressions and only one of them is reachable here,
/// because this route classifies nothing for a remote.
/// </para>
/// </summary> /// </summary>
private void TryCommitAcceptedWireCell( private void TryCommitAcceptedWireCell(
WorldSession.EntityPositionUpdate update) WorldSession.EntityPositionUpdate update)
@ -400,6 +461,17 @@ public sealed class RuntimeLiveEntitySessionController
return; return;
} }
// The bool is discarded, where the graphical caller
// (LiveEntityRuntime.RebucketLiveEntity) treats false as
// ThrowAfterCommittedProjectionChange. That is not a suppressed
// failure: false means `Entities.IsCurrent(canonical)` went stale, and
// TryGetActive above returned the CURRENT record synchronously three
// statements earlier on the same thread, with nothing in between that
// can retire it. The graphical caller needs the test because it has
// already published spatial/presentation changes by that point and a
// stale canonical would leave them orphaned; this route publishes
// nothing ahead of the commit, so there is no half-applied state to
// detect. Asserting on it would be asserting on an unreachable value.
_ = Entities.CommitWireCellRebucket( _ = Entities.CommitWireCellRebucket(
canonical, canonical,
update.Position.LandblockId); update.Position.LandblockId);

View file

@ -659,9 +659,16 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.NotEqual(0u, placedCell); Assert.NotEqual(0u, placedCell);
// (a) A stale position sequence is Rejected by the timestamp gate. // (a) A stale position sequence is Rejected by the timestamp gate.
// The cell is 0x…0011 rather than the 0x…0041 this fixture used
// before the C5b follow-up: a low word above 0x40 is not a legal
// landcell (LandDefs.CellLowInRange, retail
// LandDefs::inbound_valid_cellid @0x004979A0), so once this route
// gained the graphical route's payload gate the packet would have
// been refused for its CELL and this assertion would have passed
// without the timestamp gate ever being consulted.
sink.PositionUpdated(PositionUpdate( sink.PositionUpdated(PositionUpdate(
spawn.Guid, spawn.Guid,
0x01010041u, 0x01010011u,
positionX: 13f, positionX: 13f,
positionSequence: 1)); positionSequence: 1));
Assert.Equal(placedCell, remote.FullCellId); Assert.Equal(placedCell, remote.FullCellId);
@ -674,6 +681,110 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.Equal(0x0202FFFFu, remote.CanonicalLandblockId); Assert.Equal(0x0202FFFFu, remote.CanonicalLandblockId);
} }
/// <summary>
/// C5b follow-up (retail F2 / architecture L-A): the no-window route
/// refuses an invalid wire payload with the same rule, at the same
/// point, as the graphical route's <c>payloadIsValid</c> gate
/// (<c>LiveEntityNetworkUpdateController.OnPosition</c> →
/// <c>ProjectileController.CanAcceptPositionPayload</c> →
/// <c>LiveEntityInboundAuthorityGate</c>'s <c>!payloadIsValid</c>
/// return).
///
/// <para>
/// The three shapes are the three terms of that rule. <c>LandblockId</c>
/// 0 is the one that mattered: <c>LandDefs.InboundValidCellId</c> rejects
/// a 0 low word, and since D1 an unvalidated 0 flowed straight into
/// <c>CommitWireCellRebucket</c>, whose own doc calls cell 0 + landblock
/// 0 "the withdrawal shape" — silently de-residencing the entity in the
/// field <c>RuntimeEntityObjectViews.Snapshot</c> hands every bot as
/// <c>CellId</c>.
/// </para>
///
/// <para>
/// The last assertion is the discriminating one, and it is what makes
/// this a BEFORE-THE-MERGE test rather than a before-the-commit test: a
/// valid packet at the SAME position sequence the three refused packets
/// carried still applies. If the refusal sat anywhere downstream of
/// <c>TryApplyPosition</c>, the timestamp gate would have consumed that
/// sequence and the follow-up would be <c>Rejected</c>.
/// </para>
/// </summary>
[Fact]
public void InvalidPositionPayload_IsRefusedBeforeTheMerge_InANoWindowHost()
{
using StartedRuntime started = StartRuntime();
GameRuntime runtime = started.Runtime;
CommitLandblockCollision(runtime, 0x01010000u);
RuntimeFirstEntryDriveController drive = CreateDrive(runtime);
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9000),
new FixtureTransport());
var controller = new RuntimeLiveEntitySessionController(
runtime,
session,
worldProjection: new FixtureWorldProjection());
LiveEntitySessionSink sink = controller.CreateSink();
WorldSession.EntitySpawn spawn =
SpawnAt(0x70000051u, incarnation: 1, 0x01010001u);
sink.Spawned(spawn);
DrainFirstEntry(runtime, drive);
Assert.True(runtime.EntityObjects.Entities.TryGetActive(
spawn.Guid,
out RuntimeEntityRecord remote));
uint placedCell = remote.FullCellId;
uint placedLandblock = remote.CanonicalLandblockId;
float placedX = remote.Snapshot.Position!.Value.PositionX;
Assert.NotEqual(0u, placedCell);
const ushort refusedSequence = 7;
// (a) LandblockId 0 — the withdrawal shape.
sink.PositionUpdated(PositionUpdate(
spawn.Guid,
0u,
positionX: 20f,
positionSequence: refusedSequence));
Assert.Equal(placedCell, remote.FullCellId);
Assert.Equal(placedLandblock, remote.CanonicalLandblockId);
Assert.Equal(placedX, remote.Snapshot.Position!.Value.PositionX);
// (b) A NaN origin component — retail Frame::IsValid @0x00534ED0.
sink.PositionUpdated(PositionUpdate(
spawn.Guid,
0x01010031u,
positionX: float.NaN,
positionSequence: refusedSequence));
Assert.Equal(placedCell, remote.FullCellId);
Assert.Equal(placedX, remote.Snapshot.Position!.Value.PositionX);
// (c) A non-finite velocity — the graphical gate's third term.
WorldSession.EntityPositionUpdate infiniteVelocity = PositionUpdate(
spawn.Guid,
0x01010031u,
positionX: 22f,
positionSequence: refusedSequence) with
{
Velocity = new System.Numerics.Vector3(
float.PositiveInfinity,
0f,
0f),
};
sink.PositionUpdated(infiniteVelocity);
Assert.Equal(placedCell, remote.FullCellId);
Assert.Equal(placedX, remote.Snapshot.Position!.Value.PositionX);
// The discriminator: POSITION_TS was never consumed.
const uint movedCell = 0x01010031u;
sink.PositionUpdated(PositionUpdate(
spawn.Guid,
movedCell,
positionX: 23f,
positionSequence: refusedSequence));
Assert.Equal(movedCell, remote.FullCellId);
Assert.Equal(23f, remote.Snapshot.Position!.Value.PositionX);
}
/// <summary> /// <summary>
/// D1's missile gate. The graphical route sends a BOUND projectile's /// D1's missile gate. The graphical route sends a BOUND projectile's
/// accepted Position to the canonical projectile placement owner and /// accepted Position to the canonical projectile placement owner and
@ -737,7 +848,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests
Assert.NotNull(bound.Projectile); Assert.NotNull(bound.Projectile);
Assert.Null(unbound.Projectile); Assert.Null(unbound.Projectile);
const uint movedCell = 0x01010051u; // 0x…0012, not the 0x…0051 this fixture used before the C5b
// follow-up: a low word above 0x40 is not a legal landcell
// (LandDefs.CellLowInRange), so the payload gate this route now
// shares with the graphical host would refuse BOTH packets and the
// missile distinction under test would never be reached.
const uint movedCell = 0x01010012u;
sink.PositionUpdated(PositionUpdate( sink.PositionUpdated(PositionUpdate(
boundGuid, boundGuid,
movedCell, movedCell,