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

@ -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

View file

@ -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);