acdream/docs/research/2026-08-04-c4-route-5-scoping.md
Erik 07e979393b docs: scope C4 route 5 (projectile authoritative placement)
Route 5 is RuntimePositionEntityKind.Projectile + ProjectileAuthoritative, and
the entity kind turns out to be behaviourally inert in the classifier —
ClassifyAcceptedPosition has exactly one EntityKind test (LocalPlayer, :349);
Projectile and Remote fall through identical code from :393, differing only in
OperationKind. Route 5 is an execution/ownership consolidation, not a
classification change.

Roughly half already shipped: the Create half and the residence-window Position
half are canonical and live. What remains is the post-residence accepted
Position, short-circuited before the classifier at
LiveEntityNetworkUpdateController.cs:1428-1448 — ~180 non-comment lines.

Retail has no missile branch: HandleReceivedPosition @0x00453FD0 and
MoveOrTeleport @0x00516330 route a projectile through the identical remote arm
@0x0045414D, ConstrainTo @0x00454272 IS armed for projectiles, and for an
in-flight missile with a cell and no teleport/contact retail does nothing
(return 0 @0x0051636D). This forecloses the plausible "projectiles are special"
implementation before anyone writes it.

Records that route 5 must land AFTER 4b-2 — it widens
RuntimeRemotePlacementDriveController.OwnsPlacement, which excludes
ProjectileAuthoritative today, leaving the far and teleport/cell-less branches
with no owner.

Names the gate problem honestly: ACE never sends UpdatePosition for a missile
(the one site is commented out at WorldObject_Tick.cs:333-334), so the Position
half is unreachable in ordinary play and has no cheap live trigger. The gate
covers the Create half and regressions; the four dispositions are test-gated.

Corrects a prior inventory claim: CommitProjectileCell is not ad hoc, it routes
into the shared CommitCanonicalCell (RuntimePhysicsState.cs:1376-1397). The
bypass is SnapToCell plus the InWorld/shadow tail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:26:30 +02:00

31 KiB

C4 route 5 — projectile authoritative placement: scoping (2026-08-04)

Research only. Nothing implemented, nothing edited under src/ or tests/. All reads are against the clean tree at d5bdc355 (git status --porcelain empty at the time of reading; route 4b-2 was in flight in another agent's session and had not yet written).

Headline: route 5 is much smaller than route 4, and roughly half of it has already shipped. The Create half and the residence-window Position half are canonical today (C3b/C3c). What remains is one execution seam, three deletions, and two policy decisions that the campaign has not yet made. It should NOT split. But one of those policy decisions — the near-Interpolate branch — has no executable machinery in acdream at all, and if the contract does not settle it up front the implementer will reproduce route 4b-2's "deleted the only handler for a live branch" failure exactly.


1. What route 5 actually is

1.1 The classifier surface

RuntimePositionEntityKind.Projectile (src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs:13) is route 5's entity kind. It has exactly one behavioural effect anywhere in the classifier:

  • OperationKind (:559-570) maps it to RuntimeSetPositionOperationKind.ProjectileAuthoritative (:567-568).

That is all. ClassifyCreate (:205-273) gives a Projectile the identical SetPosition + InitialCreateFlags route as any non-local entity — the only EntityKind test in that method is is RuntimePositionEntityKind.LocalPlayer at :263-266, for the teleport hook. ClassifyAcceptedPosition (:308-473) has a single LocalPlayer branch at :349; Projectile and Remote fall through the same code from :393 onward and are byte-for-byte identical in disposition, flags, StopInterpolating, and ConstrainPhase. The existing test RuntimeAuthoritativePositionRouteClassifierTests.cs:418-436 (ProjectilePosition_UsesRemoteMoveOrTeleportClassification) pins exactly that.

So the dispositions route 5 owns for an accepted Position are the same four route 4 owns, discriminated only by OperationKind == ProjectileAuthoritative:

Branch Condition (classifier line) Runs SetPosition?
SetPosition TeleportAdvanced || CommittedCellId == 0 (:399) yes
NoPositionOperation !effectiveContact (:425) no
Interpolate contact, PlayerDistance < 96 m (:454-459) no
SetPositionSimple contact, PlayerDistance >= 96 m (:454-459) yes

Consequence worth stating plainly: route 5 is not a classification change at all. Whether a projectile's accepted Position is tagged Projectile or Remote, the classifier returns the same route. Route 5 is an execution and ownership consolidation — which owner runs the route, and which ledger the operation lands in.

1.2 What is ALREADY canonical

(a) The Create half — DONE, live in production. RuntimeInitialCreateResidenceState.Begin decides the kind at :591-595:

RuntimePositionEntityKind entityKind = isLocalPlayer
    ? RuntimePositionEntityKind.LocalPlayer
    : (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0
        ? RuntimePositionEntityKind.Projectile
        : RuntimePositionEntityKind.Remote;

This is the only production producer of RuntimePositionEntityKind.Projectile in the tree (verified by grep across src/ and tests/). The first-entry conductor accepts it explicitly: RuntimeRemoteFirstEntryState.cs:311-313 admits RemoteAuthoritative or ProjectileAuthoritative. And App has already been cut over — DatLiveEntityProjectionMaterializer.cs:848-865 deliberately skips _projectiles.TryBind while initialResidenceActive, with a comment naming the exact bug that forced it ("the conductor then correctly rejected the unexpected owner and the missile remained permanently cell-less"). TryBind is retried on the committed projection-visible edge (ProjectileController.cs:870-897), by which point the canonical body exists and has been placed by the conductor.

(b) The residence-window Position half — DONE, dormant-but-wired. RuntimeInitialCreateContinuationExecutor.ApplyPositionAction:1892 recovers the kind from the lease's OperationKind (EntityKindOf, :2571-2579, which maps ProjectileAuthoritativeProjectile at :2577-2578) and classifies through the one shared request builder RuntimeAcceptedPositionRouteRequests.Build (:1914-1928). A Position arriving while a missile's initial residence is open is already fully canonical.

Note the executor's own recorded gap at :2019-2026: for Interpolate/NoPositionOperation/AwaitFreshPosition it emits a typed trace and returns — "Binding to the live interpolation owner is cutover work." That gap is shared with route 4 and is one half of the policy question in §5 below.

1.3 What is still a duplicate authority

The post-residence (steady-state) accepted Position for a live projectile. Entry point:

  • src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1428-1448 — inside OnPosition, positioned AFTER the remote-teleport hook (:1417-1426) and BEFORE route 4a's classification (:1473-1481). It calls _projectileController.ApplyAuthoritativePosition(...) and returns when it returns true, so a projectile never reaches the classifier at all today.
  • src/AcDream.App/Physics/ProjectileController.cs:492-590ApplyAuthoritativePosition (two overloads + doc comment, 99 lines). Validates, then delegates.
  • src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs:301-424 — the actual authority (124 lines): body.Orientation = orientation (:345), body.SnapToCell(fullCellId, worldPosition, cellLocalPosition) (:346), body.State = record.FinalPhysicsState (:347), a velocity commit (:348-358), CommitProjectileCell (:370-375), the presentation acknowledgement (:387), and the InWorld/Activate/shadow-sync tail (:390-422).

CommitProjectileCell itself is not a bypass — RuntimePhysicsState.cs:1376-1397 routes it into the shared CommitCanonicalCell (:2138-2160), the same canonical-cell writer the remote path uses. The 2026-08-02 inventory called it "ad hoc"; that is wrong and should be corrected in the contract. The bypass is body.SnapToCell + the InWorld/shadow tail running outside the RuntimeSetPositionState transaction, not the cell commit.

1.4 Adjacent, NOT route 5

Per the inventory and confirmed by reading:

  • Per-quantum integration stays out of SetPosition. RuntimeProjectilePhysicsUpdater.TryBegin/Complete (:37-205), driven by ProjectileController.Tick (:640-766) and LiveEntityAnimationScheduler (:403-426). Complete's SnapToCell at :145-152 plus CommitProjectileCell at :158-163 is the simulation commit, not an authoritative placement. The 1,880 B/op → 944 B/op placement budget (C2, plan :155-170) is per operation; projectile integration runs per object quantum on every missile in flight. Do not touch it.
  • ApplyAuthoritativeVector (RuntimeProjectilePhysicsUpdater.cs:207-250) and ApplyAuthoritativeState (:252-299) are not placement authorities. Vector already goes through _physics.TryCommitAuthoritativeVector (:242). State's one pose write is body.SnapToCell(record.FullCellId, body.Position, ...) at :286-296 — a re-normalize of the frame into the already canonical cell on the Missile-bit rising edge, not a move. The inventory's "reduce to acknowledge-only" framing overstates what is there.

2. Retail truth

Verify each yourself; every anchor below was read in docs/research/named-retail/acclient_2013_pseudo_c.txt during this pass.

2.1 There is no projectile branch. At all.

SmartBox::UnpackPositionEvent @0x004542C0 resolves the object from CObjectMaint (@0x004542F2) and calls HandleReceivedPosition (@0x00454358) with no state/kind test.

SmartBox::HandleReceivedPosition @0x00453FD0:

  • FORCE_POSITION early return @0x0045400C requires arg2 == this->player.
  • unset_parent @0x00454129 unconditional; SetPlacementFrame @0x00454142 gated on !HasAnims.
  • if (arg2 != this->player) @0x0045414D → MoveOrTeleport @0x00454254 → ConstrainTo @0x00454272 inside if (MoveOrTeleport(...) != 0), anchored to &arg2->m_position read live (post-move)return.

A missile is not this->player, so a projectile takes the identical remote arm. No state & Missile test exists anywhere in this function.

CPhysicsObj::MoveOrTeleport @0x00516330:

if (!newer_event(POSITION_TS-ish gate)) return 0;              // @0x00516364/@0x0051636D
if (newer_event(TELEPORT_TS, arg3) || this->cell == 0) {       // @0x00516386
    teleport_hook @0x005163EF; SetFlags(0x1012) @0x00516414;
    SetPosition @0x00516420; return 1;                          // @0x00516438
}
if (arg4 != 0) {                                                // @0x0051638E — the contact bit
    if (player_distance < 96f)                                  // @0x00516390-@0x00516399
        { InterpolateTo(this, arg2, IsMovingTo(this)) @0x005163AF; return 1; }
    if (position_manager) StopInterpolating @0x005163CB;
    SetPositionSimple(this, arg2, 1) @0x005163D9; return 1;     // @0x005163E8
}
return 0;                                                       // @0x0051636D

SetPositionSimple @0x005162B0 with arg3 != 0 builds 0x1012 (Teleport|Slide|SendPositionEvent, @0x005162C4) — the classifier's AuthoritativeTeleportFlags.

Answers to the specific questions asked:

  • Does a projectile's authoritative Position take the same MoveOrTeleport path as a remote? Yes, identically. Same function, same branches, no discriminator.
  • What flags? Teleport/cell-less → 0x1012 via the explicit SetFlags @0x00516414. Far snap → 0x1012 via SetPositionSimple(…, 1) @0x005162C4. Near and airborne → no SetPosition at all.
  • Is ConstrainTo armed for projectiles? Yes — @0x00454272, on any nonzero MoveOrTeleport return, with no kind test. It is NOT armed on the airborne no-op (return 0 @0x0051636D). CPhysicsObj::ConstrainTo @0x00510520 calls MakePositionManager first, so retail creates the manager on demand for a missile.
  • Is StopInterpolating called? Only on the far branch, and only if a position_manager already exists (@0x005163C9-@0x005163CB). A missile that has never been near-interpolated has none, so the call is skipped.

2.2 player_distance is maintained for missiles

CPhysicsObj::update_object @0x00515D10 computes player_vector from Position::get_offset @0x00515D5B and stores player_distance @0x00515D95 for every active, unparented, in-cell object — which includes every in-flight missile. So retail's near/far test is meaningful for projectiles.

(BN artifact note: the decomp shows player_distance taking player_vector.x @0x00515D7B-@0x00515D95. That is the standard x87-elision artifact for a vector magnitude; acdream's Euclidean PlayerDistance is the right reading. Flagging it so nobody "fixes" it to .x.)

2.3 MoveOrTeleport discards the velocity argument

MoveOrTeleport(this, arg2, arg3, arg4, arg5) declares arg5 = AC1Legacy::Vector3 const* and never references it in the decompiled body @0x00516330-@0x00516438. HandleReceivedPosition threads arg6 into it @0x00454254 and does nothing else with it. Retail's velocity authority is set_velocity via VectorUpdate, not the Position event.

Confidence: the parameter is textually unreferenced. I did not byte-verify against refs/acclient.exe that BN did not elide a use. Treat as strong but not byte-confirmed; it matters only because acdream currently does commit a velocity here (§3, item 4).

2.4 What retail actually does to an in-flight missile — mostly nothing

Chain the three facts: an in-flight missile has a non-null cell, no advancing TELEPORT_TS, and no ground contact. arg4 == 0return 0 @0x0051636D. Nothing is written, and ConstrainTo is skipped.

The contact bit's provenance is ACE PositionPack.BuildFlags (references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs:72-73): IsGrounded is set from TransientState & OnWalkable. A flying missile is not OnWalkable.

Not established: I did not observe a live missile Position packet, because (see §6) ACE does not send one. The claim "an in-flight missile's Position packet would carry IsGrounded == false" is an inference from ACE's flag definition, not a measurement.


3. The duplicate authority to delete

Exact deletion targets, with the fabricated values called out.

# Site Lines What it is
1 src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs:301-424 124 The real authority: SnapToCell :346, velocity commit :348-358, CommitProjectileCell :370-375, InWorld/Activate/shadow tail :390-422
2 src/AcDream.App/Physics/ProjectileController.cs:492-590 99 Two ApplyAuthoritativePosition overloads + doc; validation, currency closures, render-pose projection :583-586
3 src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:1428-1448 21 The early-return call site that keeps projectiles out of the classifier

Raw total 244 lines; roughly 180 non-comment.

Fabricated values on those paths:

  • LiveEntityNetworkUpdateController.cs:1442-1443: acceptedSpawn.Physics?.Velocity ?? System.Numerics.Vector3.Zero. This is the exact ?? Vector3.Zero shape route 4b-2's contract calls out, and it is worse than an inert default: it is passed into RuntimeProjectilePhysicsUpdater.cs:348-358, which commits it as the body's authoritative velocity whenever VelocityAuthorityVersion matches. A Position packet with no PhysicsDesc.Velocity therefore zeroes an in-flight missile's velocity. Retail's MoveOrTeleport does not consume its velocity argument at all (§2.3).
  • No duplicated distance/threshold constants exist on the projectile path — MaxPhysicsDistance = 96f and BodySnapThreshold = 4f live only in the classifier (:194) and route 4a's seam (RuntimeRemoteSteadyStatePosition.cs:43) plus the surviving 4b legacy far halves. Route 5 introduces no new copy and must not.

Not a deletion target, despite appearances:

  • ProjectileController.CanAcceptPositionPayload (:102-126). It is called unconditionally at LiveEntityNetworkUpdateController.cs:1205-1208 for every entity (it returns true for non-projectiles via != false), so it is the shared admission validator, not a projectile authority. It also does not subsume item 1's inner validation: it checks update.Position and update.Velocity, while ApplyAuthoritativePosition additionally checks the origin-translated worldPosition and a different velocity (acceptedSpawn.Physics?.Velocity).
  • ProjectileController.TryBind's create branch (:222-267) and its entity.SetPosition/ParentCellId/RebucketLiveEntity tail (:269-291). This is a placement authority — GetOrCreatePhysicsBody with a SnapToCell seeded straight from the CreateObject wire frame (:258-264) — but it is now the fallback for the paths the residence conductor does not own: initialResidenceActive == false (DatLiveEntityProjectionMaterializer.cs:857) and the late-classification route ApplyAuthoritativeStateTryBind (ProjectileController.cs:488), where an ordinary remote gains the Missile bit mid-life. Deleting it belongs to route 5 only if route 5 first proves those two paths unreachable or supplies a canonical replacement. My recommendation: leave it, record it, and let C5's deletion pass own it — see §7 trap T8.

4. Prediction / correction

This is where the subtle regression lives, and it is a real one.

The projectile prediction model (J5.6): RuntimeProjectile.PredictionAuthorityVersion (RuntimeProjectile.cs:36-39) is bumped by InvalidatePrediction(). It is bumped in exactly three places, all in RuntimeProjectilePhysicsUpdater: ApplyAuthoritativeVector:241, ApplyAuthoritativeState:272, and ApplyAuthoritativePosition:342.

That version is the sole cancellation mechanism for an in-flight split quantum:

  • TryBegin captures it into the commit (:86-87).
  • Complete re-checks it via IsSpatialCurrent/IsIdentityCurrent (:114-122, :131-140, :164-174, :200-204, :440-450).
  • App re-checks it too, in ProjectileController.IsCurrentQuantumIdentity (:850-859).

And the quantum is genuinely split across other work: LiveEntityAnimationScheduler.cs:403-426 calls TryBeginQuantum at :404-408, runs _animationHooks.Capture at :411, then CompleteQuantum at :418-422. ProjectileController.AdvanceQuantum (:773-782) is the fused variant for un-animated missiles.

The trap: if route 5 replaces ApplyAuthoritativePosition with a RuntimeSetPositionState route and does not invalidate the projectile's prediction version at the same point, then (a) an already-begun quantum's Complete will no longer abort, and will write the pre-correction integrated pose over the freshly committed placement, and (b) the version stops advancing on this channel, weakening the currency checks that the Vector and State channels still rely on. The fix is trivial once seen — the contract must require the replacement to invalidate prediction at the same point in the sequence retail's SetPosition would clobber the body — but it is invisible if you only read the classifier.

Not established: whether the network drain can actually land between :408 and :418 in a single frame (both are on the update thread; the wire pump is a separate frame phase, and _animationHooks.Capture can dispatch effect callbacks). I could not settle re-entrancy by reading alone. The mechanism — InvalidatePrediction being the only cancel — is established regardless, and that is enough to make the requirement binding.

Second prediction interaction: retail's near branch is InterpolateTo(this, arg2, IsMovingTo(this)) @0x005163AF, and CPhysicsObj::IsMovingTo @0x0050EB10 returns nonzero only when a MovementManager exists and is moving-to. A missile has no MovementManager, so retail passes 0. Any acdream near-branch policy that wants to be retail-shaped must pass isMovingTo: false for projectiles.


5. The two policy decisions the contract must make

Both are of the shape the 4b-2 contract names: "deleting the legacy block removes the only handler for a live classification." Neither can be deferred into a code comment.

5.1 Interpolate (near, in contact) has NO executable machinery

Route 4a's seam is RuntimeRemoteSteadyStatePosition.ApplyInterpolate (:113-149) and it takes a RemoteMotion — it drives remote.Interp (the interpolation queue) and remote.Body. TryArmConstraintAfterOperation (:165-180) requires remote.Host to reach host.PositionManager.ConstrainTo.

A projectile has none of that. RuntimeProjectile (RuntimeProjectile.cs:17-40) is { Body, CollisionSphere, PredictionAuthorityVersion }. No EntityPhysicsHost, no PositionManager, no InterpolationManager, no ConstraintManager. A pure missile also never acquires a RemoteMotion — that is created on the 0xF74C motion path, and ProjectileController.HandlesMovement (:599-602) plus LiveEntityAnimationScheduler.cs:256-257,336 keep the two owners disjoint while the Missile bit is set. (Coexistence is representable — ProjectileController.Tick:756 handles record.RemoteMotionRuntime is RemoteMotion — but it is the adopted-body case, not the ordinary arrow/bolt.)

So the contract must choose, explicitly:

  1. Build retail's PositionManager chain for projectiles. Retail-exact (ConstrainTo @0x00510520 does MakePositionManager on demand). Also the only way to be exact about StopInterpolating @0x005163CB and the leash @0x00454272. Large: a new interpolation/constraint owner for a body type that has none, plus its interaction with the per-quantum stepper. If this is chosen, it is route 5b and route 5 splits.
  2. State an acdream divergence: a projectile's near-Interpolate and its ConstrainTo are no-ops, with a register row citing @0x005163AF and @0x00454272 and the reason (no PositionManager exists for a ballistic body; the classifier's own ConstrainPhase is honoured for the placement branches only). Cheap, honest, and — given §6 — has no observable production effect.

My recommendation is (2), with the row filed in the same commit. Choosing (1) for a branch that ACE never triggers would be building machinery for a packet that does not exist.

5.2 The placement dispositions have no owner

RuntimeRemotePlacementDriveController.OwnsPlacement (src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs:274-278) requires:

route.OperationKind is RuntimeSetPositionOperationKind.RemoteAuthoritative
&& route.Disposition is SetPosition or SetPositionSimple
&& (route.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0

ProjectileAuthoritative is excluded. So the moment route 5 stops short-circuiting at LiveEntityNetworkUpdateController.cs:1432, the SetPosition (teleport / cell-less) and SetPositionSimple (far) branches for a projectile have no handler at all.

The controller is otherwise entirely kind-agnostic: the service window, the per-entity _pending map, the _awaitingAcknowledgement ledger, the refuse-not-park decision, and DetachRoute's cancellation all read only the record and the token. Widening the first clause to is RemoteAuthoritative or ProjectileAuthoritative (plus its class/method doc) is ~10 lines and is the right move. Do not clone a sibling controller.

Sequencing constraint: that file is route 4b-2's active edit surface, and route 4b-3 will touch it again. Route 5 must land after 4b-2, and its contract should say so.


6. The connected gate — and an honest problem with it

ACE never sends an UpdatePosition for a missile. The only physics-tick site that would is commented out: references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:333-334 (/*if (PhysicsObj.IsGrounded) SendUpdatePosition();*/), inside the branch explicitly gated on (PhysicsObj.State & PhysicsState.Missile) != 0 at :265. Every live SendUpdatePosition / GameMessageUpdatePosition caller in ACE.Server is a Player, Creature/Monster, Pet, GamePiece, inventory move, or an admin command (grepped exhaustively). The projectile classes broadcast GameMessageVectorUpdate (SpellProjectile.cs:238) and GameMessageSetState (:229) on impact — never a Position.

So route 5's authoritative-Position half is unreachable in ordinary play against ACE, and there is no cheap live trigger: the one admin path (AdminCommands.cs:4772, move-selected-object-to-me) requires selecting a projectile inside its ~5 s ProjectileTimeout lifetime.

The contract must say this rather than invent a gate. Concretely:

What the user CAN observe (the Create half + no-regression):

  1. In peace mode with a bow: fire an arrow at a target ~20 m away. The arrow must appear at the launch point immediately, fly a clean arc, and vanish on impact.
  2. Cast a Force Bolt / Flame Bolt at a target across a landblock boundary (stand near a seam, target something on the other side). The bolt must not freeze, teleport, or vanish at the seam.
  3. Fire indoors, in a dungeon: the projectile must respect the EnvCell and impact on the wall rather than passing through.
  4. Fire at a target at bow max range (~80 m) and confirm the projectile does not stall or snap.
  5. After each impact, walk through the impact point. An invisible collider there is the loud regression — it means a projectile's shadow/cell state survived its retirement.

Regression signatures: projectile spawns at the origin or at the player's feet instead of the launch point; hangs motionless; disappears on frame 1; appears only after it has already travelled; leaves an invisible-but-solid body (the #184 signature); an arrow that visibly slows or stops mid-flight (that would be the §3 velocity-zeroing path, which route 5 removes — so if it is present today, it should stop).

What the gate cannot cover: the four accepted-Position dispositions. Those must be gated by focused Runtime + App tests, with the ACE citation above recorded as the reason. Do not let a green connected gate be presented as evidence for the Position half.


7. Traps

T1 — the near-Interpolate branch loses its only handler. §5.1. Highest risk. Today ApplyAuthoritativePosition handles every projectile Position shape by hard-correcting. Route 5 replaces it with four dispositions, two of which (Interpolate, NoPositionOperation) have no projectile executor. The airborne one is correct as a no-op (retail returns 0). The near one is not, and silently dropping it is the same failure as 4b-2's null/Rejected* trap.

T2 — OwnsPlacement excludes ProjectileAuthoritative. §5.2. The far and teleport/cell-less branches have no owner the instant the short-circuit is removed.

T3 — prediction invalidation. §4. InvalidatePrediction() is the only mechanism that aborts an in-flight split quantum; the SetPosition route does not call it.

T4 — the entity kind is decided ONCE, at Create. RuntimeInitialCreateResidenceState.cs:591-595 reads record.FinalPhysicsState & Missile at Create time and freezes it into the lease's OperationKind. But the Missile bit changes at runtime: ACE clears it on impact (SpellProjectile.cs:229 broadcasts the new state), and ApplyAuthoritativeStateTryBind (ProjectileController.cs:473-488) is the path where an ordinary object becomes a missile. Meanwhile RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition:622 hardcodes RuntimePositionEntityKind.Remote. Route 5 must define one per-packet discriminator from the canonical FinalPhysicsState, and the remote arm and the projectile arm must be provably mutually exclusive on the same input — otherwise both or neither will own a packet at the moment the bit flips. Because the classifier is disposition-identical for the two kinds (§1.1), a mis-tag is silent: the route is right and only the ledger/owner is wrong.

T5 — ApplyAuthoritativePosition returns true on an invalid payload. ProjectileController.cs:550-554 and RuntimeProjectilePhysicsUpdater.cs:329-339 both return true — "handled, ignore" — so the packet does not fall through to the generic remote path. Their validation is not subsumed by CanAcceptPositionPayload (§3). Any replacement must preserve the swallow, or an invalid projectile packet starts being routed as an ordinary remote.

T6 — carrying AP-87 onto a branch that does not need it. Same shape as 4b-2's warning. AP-87's bodyToTarget > 4 m / !willBeDrTicked guards (RuntimeRemoteSteadyStatePosition.cs:43,130-138) exist to stop an unplaced remote body being enqueued into an interpolation queue. A projectile has no queue. Do not copy them in. Equally: do not delete the near-branch copies — those are 4a's and still load-bearing.

T7 — routing per-quantum integration through SetPosition. The one explicit prohibition carried from the original route-5 requirement, and the allocation budget is the reason. RuntimeProjectilePhysicsUpdater.Complete (:94-205) runs per quantum per in-flight missile.

T8 — deleting TryBind's create branch as "obviously superseded". ProjectileController.cs:222-267 looks like dead legacy now that C3c owns Create, but it is still the live path for initialResidenceActive == false (DatLiveEntityProjectionMaterializer.cs:857) and for late Missile classification (ProjectileController.cs:488). Same class as the 4a review's R1 (a "duplicate" that turned out to be the only handler for a real case).

T9 — a second snapshot store for the same state. The _pending / _awaitingAcknowledgement maps in RuntimeRemotePlacementDriveController are keyed by RuntimeEntityKey and are already per-entity. Adding a projectile-specific pending map alongside them would recreate the two-stores-for-one-state shape the campaign has hit twice. Widen the existing controller; do not add a parallel one.

T10 — the register. No projectile placement row exists anywhere in docs/architecture/retail-divergence-register.md (grepped). Route 5 introduces at least one deviation (§5.1 option 2) and retires at least one (the velocity zeroing, §3). Both rows land in the same commit as the behaviour.


8. Line budget, and whether route 5 splits

Calibration: route 4a = 364 production lines; 4b-1 = 230 + a 57-line park fix; 4b-2 budgeted at 350-500.

Item Non-comment production lines
Delete the three sites in §3 ~180 removed
Runtime projectile steady-state seam (airborne no-op + the §5.1 policy + prediction invalidation) 60-100
Projectile classification entry (extend ClassifyRemoteAcceptedPosition with a kind parameter, or a sibling) 10-35
Widen OwnsPlacement + doc ~10
App call-site rewrite in OnPosition (classify → own → skip generic → project committed placement) 40-70
Total added 120-215

Estimate: 250-400 non-comment production lines touched, of which 120-215 are new. Test work is the larger share as usual — roughly 400-700 lines (Runtime seam tests per disposition, the prediction-invalidation test, a behavioural App test proving the generic path no longer runs for a projectile).

Route 5 should NOT split — conditional on §5.1 resolving to option 2. If the contract chooses to build a PositionManager/InterpolationManager chain for projectiles, that is a separate 5b landing of its own and the estimate above does not cover it.

Sequencing: route 5 lands after 4b-2 and, preferably, after 4b-3, because it edits RuntimeRemotePlacementDriveController and LiveEntityNetworkUpdateController.OnPosition — both of which 4b-2/4b-3 are rewriting heavily. A parallel route-5 landing would be exactly the "coupled plan slices in parallel" failure the project has already recorded.


9. Explicitly not established

  1. Whether the network pump can land a packet between TryBeginQuantum and CompleteQuantum (§4). Would be settled by an ACDREAM_PROBE_* counter on a straddled Complete returning false, or by reading the frame graph's phase ordering end to end.
  2. Whether retail's MoveOrTeleport genuinely ignores its velocity argument, or BN elided a use (§2.3). Would be settled by tools/pdb-extract byte-decode of 0x00516330-0x00516438, or a cdb breakpoint at @0x00454254 dumping arg6 and comparing the object's velocity before and after.
  3. Whether an in-flight missile's Position packet would carry IsGrounded == false (§2.4) — inferred from ACE's PositionPack.cs:72-73, never observed, because ACE does not emit the packet.
  4. Whether initialResidenceActive == false is actually reachable for a Missile-state top-level Create in the graphical host (§3, T8). It is plainly reachable in the content-less headless path (C3c: "content-less headless keeps pre-flip direct registration") and for Parented/PickedUp Creates. For a graphical top-level missile Create it depends on RuntimeInitialCreateResidenceState.CanAcceptCreate (:559-573) never refusing, which I did not prove.
  5. Whether any retail server ever sent a projectile Position. Only ACE was checked. Retail's client clearly supports it (§2.1); acdream targets ACE.