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

604 lines
31 KiB
Markdown

# 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 `ProjectileAuthoritative`
`Projectile` 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-590`
`ApplyAuthoritativePosition` (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 == 0``return 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 `ApplyAuthoritativeState``TryBind` (`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
`ApplyAuthoritativeState``TryBind` (`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.