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 by735f0a72and fixed only atff100cf3. 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:
parent
ff100cf33f
commit
9ee9c1a1a6
7 changed files with 455 additions and 21 deletions
|
|
@ -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.
|
||||
- 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:
|
||||
0x0045402B–0x00454054 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
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -2,6 +2,27 @@
|
|||
|
||||
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
|
||||
|
||||
- Worktree `C:\Users\erikn\source\repos\acdream\.claude\worktrees\peaceful-visvesvaraya-e0a196`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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)):
|
||||
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
|
||||
00454074 SmartBox::BlipPlayer(this, &var_48)
|
||||
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
|
||||
directly makes the record resident").
|
||||
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.
|
||||
It returns @0x0045409D **before `unset_parent` @0x00454129 and before the
|
||||
`SetPlacementFrame` gate @0x00454137** — a ForcePosition never unparents
|
||||
and never installs a placement frame. acdream's exact analog exists
|
||||
upstream: `PhysicsTimestampGate.TryAcceptPositionEvent:190-203` produces
|
||||
the `ForcePosition` disposition only when `isLocalPlayer &&
|
||||
IsNewer(FORCE_POSITION_TS) && teleport == _timestamps[Teleport]` —
|
||||
retail's Gate A pair, verified at HEAD. **A remote entity can never
|
||||
receive the `ForcePosition` disposition** (`isLocalPlayer` guard at
|
||||
`:190`), so the flag truth table below needs no entity-kind term.
|
||||
the entity is the player, FORCE_POSITION_TS advanced, and the wire
|
||||
TELEPORT_TS is **not older** than the stored one. It returns @0x0045409D
|
||||
**before `unset_parent` @0x00454129 and before the `SetPlacementFrame`
|
||||
gate @0x00454137** — a ForcePosition never unparents and never installs
|
||||
a placement frame. acdream's analog exists upstream:
|
||||
`PhysicsTimestampGate.TryAcceptPositionEvent:190-203` produces the
|
||||
`ForcePosition` disposition when `isLocalPlayer &&
|
||||
IsNewer(FORCE_POSITION_TS) && teleport == _timestamps[Teleport]`.
|
||||
**CORRECTED 2026-08-05 (see §15): that third term is retail's Gate A pair
|
||||
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.**
|
||||
`unset_parent` @0x00454129 runs for every accepted non-Gate-A Position
|
||||
(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
|
||||
teleport advance falls through to `Apply`, and
|
||||
`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
|
||||
gate, W2, the arm dispatch, W3, the unified tail, AP-87's catch-up,
|
||||
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
|
||||
recipe item 1 (pickup-then-drop, several times) is the positive evidence for
|
||||
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` 0x0045402B–0x00454054:
|
||||
|
||||
```
|
||||
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**.
|
||||
|
|
|
|||
|
|
@ -2092,6 +2092,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
uint spatialCellOrLandblockId,
|
||||
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);
|
||||
uint committedFullCell =
|
||||
(spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using AcDream.Core.Net.Messages;
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.World;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
|
@ -223,6 +224,42 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
private void OnPositionUpdated(
|
||||
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 =
|
||||
update.Guid == _runtime.PlayerIdentity.ServerGuid;
|
||||
PlayerMovementController? localController =
|
||||
|
|
@ -358,9 +395,12 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
|
||||
/// <summary>
|
||||
/// D1 (C5b architecture review): commits the accepted wire cell to
|
||||
/// canonical residency for a no-window host, under the same
|
||||
/// reachability rules the graphical <c>OnPosition</c> route applies to
|
||||
/// AD-60's W2. The committed VALUE is
|
||||
/// canonical residency for a no-window host, under reachability rules
|
||||
/// derived one by one from the graphical <c>OnPosition</c> route's own
|
||||
/// 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 —
|
||||
/// one rule, shared by both hosts, including its landblock-vs-cell
|
||||
/// branch.
|
||||
|
|
@ -387,6 +427,27 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
/// operation kind. Committing a wire cell for a projectile here would
|
||||
/// invent residency a placement route owns.
|
||||
/// </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>
|
||||
private void TryCommitAcceptedWireCell(
|
||||
WorldSession.EntityPositionUpdate update)
|
||||
|
|
@ -400,6 +461,17 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
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(
|
||||
canonical,
|
||||
update.Position.LandblockId);
|
||||
|
|
|
|||
|
|
@ -659,9 +659,16 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
Assert.NotEqual(0u, placedCell);
|
||||
|
||||
// (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(
|
||||
spawn.Guid,
|
||||
0x01010041u,
|
||||
0x01010011u,
|
||||
positionX: 13f,
|
||||
positionSequence: 1));
|
||||
Assert.Equal(placedCell, remote.FullCellId);
|
||||
|
|
@ -674,6 +681,110 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
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>
|
||||
/// D1's missile gate. The graphical route sends a BOUND projectile's
|
||||
/// accepted Position to the canonical projectile placement owner and
|
||||
|
|
@ -737,7 +848,12 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
Assert.NotNull(bound.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(
|
||||
boundGuid,
|
||||
movedCell,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue