From 07e979393b0ec9b70f7c4eda58165aa817ab9540 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 04:26:30 +0200 Subject: [PATCH] docs: scope C4 route 5 (projectile authoritative placement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../research/2026-08-04-c4-route-5-scoping.md | 604 ++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 docs/research/2026-08-04-c4-route-5-scoping.md diff --git a/docs/research/2026-08-04-c4-route-5-scoping.md b/docs/research/2026-08-04-c4-route-5-scoping.md new file mode 100644 index 00000000..08da372f --- /dev/null +++ b/docs/research/2026-08-04-c4-route-5-scoping.md @@ -0,0 +1,604 @@ +# 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.